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(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) 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) 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) 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): """ 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)) def offset_to(self, dx, dy, skycoord_offset=False): """ 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 ``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) @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) def zoom(self, val): """ Zoom in or out by the given factor. Parameters ---------- val : int The zoom level to zoom the image. See `zoom_level`. """ self.zoom_level = self.zoom_level * val @property def is_marking(self): """ `True` if in marking mode, `False` otherwise. Marking mode means a mouse click adds a new marker. This does not affect :meth:`add_markers`. """ return self._is_marking def start_marking(self, marker_name=None, marker=None): """ Start marking, with option to name this set of markers or to specify the marker style. """ self._cached_state = dict(click_center=self.click_center, click_drag=self.click_drag, scroll_pan=self.scroll_pan) self.click_center = False self.click_drag = False # Set scroll_pan to ensure there is a mouse way to pan self.scroll_pan = True self._is_marking = True if marker_name is not None: self._validate_marker_name(marker_name) self._interactive_marker_set_name = marker_name self._marktags.add(marker_name) else: self._interactive_marker_set_name = \ self._interactive_marker_set_name_default if marker is not None: self.marker = marker def stop_marking(self, clear_markers=False): """ Stop marking mode, with option to clear markers, if desired. Parameters ---------- clear_markers : bool, optional If ``clear_markers`` is `False`, existing markers are retained until :meth:`reset_markers` is called. Otherwise, they are erased. """ if self.is_marking: self._is_marking = False self.click_center = self._cached_state['click_center'] self.click_drag = self._cached_state['click_drag'] self.scroll_pan = self._cached_state['scroll_pan'] self._cached_state = {} if clear_markers: self.reset_markers() @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 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))) 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) 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) 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() 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') 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)
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) width, 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 see ``ImportError``. """ def __init__(self, logger=None, width=500, height=500, use_opencv=True): super().__init__() # TODO: Is this the best place for this? if use_opencv: from ginga import trcalc trcalc.use('opencv') self._viewer = EnhancedCanvasView(logger=logger) self._is_marking = False self._click_center = False self._jup_img = ipyw.Image(format='jpeg', width=width, height=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) # Marker self.marker = {'type': 'circle', 'color': 'cyan', 'radius': 20} self._marktag = 'marktag' # 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 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) except Exception: imval = 'N/A' # Same as setting pixel_coords_offset=1 in general.cfg val = 'X: {:.2f}, Y:{:.2f}'.format(data_x + 1, data_y + 1) if image.wcs.wcs is not None: ra, dec = image.pixtoradec(data_x, data_y) val += ' (RA: {}, DEC: {})'.format(raDegToString(ra), decDegToString(dec)) 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: objs = [] try: c_mark = viewer.canvas.get_object_by_tag(self._marktag) except Exception: # Nothing drawn yet pass else: # Add to existing marks objs = c_mark.objects viewer.canvas.delete_object_by_tag(self._marktag) # NOTE: By always using CompoundObject, marker handling logic # is simplified. obj = self.marker(x=data_x, y=data_y) objs.append(obj) self._marktag = viewer.canvas.add(self.dc.CompoundObject(*objs)) 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 + 1, data_y + 1)) # def _repr_html_(self): # """ # Show widget in Jupyter notebook. # """ # return display(self._widget) 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) 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) _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) 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, pixel_coords_offset=1): """ 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. pixel_coords_offset : {0, 1} Data coordinates provided are n-indexed, where n is the given value. This is ignored if ``SkyCoord`` is provided. """ if isinstance(point, SkyCoord): self._viewer.set_pan(point.ra.deg, point.dec.deg, coord='wcs') else: self._viewer.set_pan(*(np.asarray(point) - pixel_coords_offset)) def offset_to(self, dx, dy, skycoord_offset=False): """ 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 ``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) @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): self._viewer.scale_to(val, val) def zoom(self, val): """ Zoom in or out by the given factor. Parameters ---------- val : int The zoom level to zoom the image. See `zoom_level`. """ self.zoom_level = self.zoom_level * val @property def is_marking(self): """ `True` if in marking mode, `False` otherwise. Marking mode means a mouse click adds a new marker. This does not affect :meth:`add_markers`. """ return self._is_marking @is_marking.setter def is_marking(self, val): if not isinstance(val, bool): raise ValueError('Must be True or False') elif self.click_center and val: raise ValueError('Cannot set to True while in click-center mode') self._is_marking = val def stop_marking(self, clear_markers=True): """ Stop marking mode, with option to clear markers, if desired. Parameters ---------- clear_markers : bool If ``clear_markers`` is `False`, existing markers are retained until :meth:`reset_markers` is called. Otherwise, they are erased. """ self.is_marking = False 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} """ return self._marker @marker.setter def marker(self, val): marker_type = val.pop('type') if marker_type == 'circle': self._marker = functools.partial(self.dc.Circle, **val) else: # TODO: Implement more shapes raise NotImplementedError( 'Marker type "{}" not supported'.format(marker_type)) def get_markers(self, x_colname='x', y_colname='y', pixel_coords_offset=1, skycoord_colname='coord'): """ Return the locations of existing markers. Parameters ---------- x_colname, y_colname : str Column names for X and Y data coordinates. Coordinates retured are 0- or 1-indexed, depending on ``pixel_coords_offset``. pixel_coords_offset : {0, 1} Data coordinates returned are n-indexed, where n is the given value. skycoord_colname : str Column name for ``SkyCoord``, which contains sky coordinates associated with the active image. This is ignored if image has no WCS. Returns ------- markers_table : `~astropy.table.Table` or `None` Table of markers, if any, or `None`. """ try: c_mark = self._viewer.canvas.get_object_by_tag(self._marktag) except Exception as e: # No markers self.logger.warning(str(e)) return image = self._viewer.get_image() xy_col = [] if 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: radec_col = np.asarray( radec_col) # [[ra0, dec0], [ra1, dec1], ...] # 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 pixel_coords_offset != 0: xy_col += pixel_coords_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.T, names=(x_colname, y_colname)) return markers_table def add_markers(self, table, x_colname='x', y_colname='y', pixel_coords_offset=1, skycoord_colname='coord', use_skycoord=False): """ 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 ``pixel_coords_offset``. pixel_coords_offset : {0, 1} Data coordinates provided are n-indexed, where n is the given value. This is ignored if ``use_skycoord=True``. 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``. """ # TODO: Resolve https://github.com/ejeschke/ginga/issues/672 # 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_type = 'wcs' coord_val = table[skycoord_colname] coord_x = coord_val.ra.deg coord_y = coord_val.dec.deg else: # Use X,Y coord_type = 'data' coord_x = table[x_colname].data coord_y = table[y_colname].data # Convert data coordinates from 1-indexed to 0-indexed if pixel_coords_offset != 0: coord_x -= pixel_coords_offset coord_y -= pixel_coords_offset # Prepare canvas and retain existing marks objs = [] try: c_mark = self._viewer.canvas.get_object_by_tag(self._marktag) except Exception: pass else: objs = c_mark.objects self._viewer.canvas.delete_object_by_tag(self._marktag) # 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._marktag = self._viewer.canvas.add(self.dc.CompoundObject(*objs)) # TODO: Future work? def remove_markers(self, arr): """ Remove some but not all of the markers. Parameters ---------- 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? return NotImplementedError def reset_markers(self): """ Delete all markers. """ try: self._viewer.canvas.delete_object_by_tag(self._marktag) except Exception: pass @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) self._viewer.cut_levels(val[0], val[1]) @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._widget = self._jup_img elif val == 'top': self._widget = ipyw.VBox([self._jup_coord, self._jup_img]) elif val == 'bottom': self._widget = ipyw.VBox([self._jup_img, self._jup_coord]) else: raise NotImplementedError 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') 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. """ raise NotImplementedError @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. """ raise NotImplementedError # https://github.com/ejeschke/ginga/pull/665 def save(self, filename): """ Save out the current image view to given PNG filename. """ self._viewer.save_rgb_image_as_file(filename) @property def width(self): """ The width of the widget in pixels """ return int(self._jup_img.width) @property def height(self): """ The height of the widget in pixels """ return int(self._jup_img.height)
class DisplayImpl(virtualDevice.DisplayImpl): def __init__(self, display, verbose=False, dims=None, canvas_format='jpeg', *args, **kwargs): """ Initialise a ginga display canvas_type file type for displays ('jpeg': fast; 'png' : better, slow) dims (x,y) dimensions of image display widget in screen pixels """ virtualDevice.DisplayImpl.__init__(self, display, verbose=False) if dims is None: # TODO: get defaults from Jupyter defaults? width, height = 1024, 768 else: width, height = dims self._imageWidget = ipywidgets.Image(format=canvas_format, width=width, height=height) logger = get_logger("ginga", log_stderr=True, level=40) self._viewer = EnhancedCanvasView(logger=logger) self._viewer.set_widget(self._imageWidget) bd = self._viewer.get_bindings() bd.enable_all(True) self._canvas = self._viewer.add_canvas() self._canvas.enable_draw(False) self._maskTransparency = 0.8 self._redraw = True def embed(self): """Attach this display to the output of the current cell.""" return self._viewer.embed() # # Extensions to the API # def get_viewer(self): """Return the ginga viewer""" return self._viewer def show_color_bar(self, show=True): """Show (or hide) the colour bar""" self._viewer.show_color_bar(show) def show_pan_mark(self, show=True, color='red'): """Show (or hide) the colour bar""" self._viewer.show_pan_mark(show, color) def _setMaskTransparency(self, transparency, maskplane): """Specify mask transparency (percent); or None to not set it when loading masks""" if maskplane is not None: print( "display_ginga is not yet able to set transparency for individual maskplanes" % maskplane, file=sys.stderr) return self._maskTransparency = 0.01 * transparency def _getMaskTransparency(self, maskplane=None): """Return the current mask transparency""" return self._maskTransparency def _mtv(self, image, mask=None, wcs=None, title=""): """Display an Image and/or Mask on a ginga display""" self._erase() self._canvas.delete_all_objects() if image: # We'd call # self._viewer.load_data(image.getArray()) # except that we want to include the wcs # # Still need to handle the title # from ginga import AstroImage astroImage = AstroImage.AstroImage(logger=self._viewer.logger, data_np=image.getArray()) if wcs is not None: astroImage.set_wcs(WcsAdaptorForGinga(wcs)) self._viewer.set_image(astroImage) if mask: import numpy as np from matplotlib.colors import colorConverter from ginga.RGBImage import RGBImage # 8 bpp RGB[A] images # create a 3-channel RGB image + alpha maskRGB = np.zeros((mask.getHeight(), mask.getWidth(), 4), dtype=np.uint8) maska = mask.getArray() nSet = np.zeros_like(maska, dtype='uint8') R, G, B, A = 0, 1, 2, 3 # names for colours and alpha plane colorGenerator = self.display.maskColorGenerator(omitBW=True) for maskPlaneName, maskPlaneNum in mask.getMaskPlaneDict().items(): isSet = maska & (1 << maskPlaneNum) != 0 if (isSet == 0).all(): # no bits set; nowt to do continue color = self.display.getMaskPlaneColor(maskPlaneName) if not color: # none was specified color = next(colorGenerator) elif color.lower() == "ignore": continue r, g, b = colorConverter.to_rgb(color) maskRGB[:, :, R][isSet] = 255 * r maskRGB[:, :, G][isSet] = 255 * g maskRGB[:, :, B][isSet] = 255 * b nSet[isSet] += 1 alpha = self.display.getMaskTransparency( ) # Bug! Fails to return a value if alpha is None: alpha = self._getMaskTransparency() maskRGB[:, :, A] = 255 * (1 - alpha) maskRGB[:, :, A][nSet == 0] = 0 nSet[nSet == 0] = 1 # avoid division by 0 for C in (R, G, B): maskRGB[:, :, C] //= nSet rgb_img = RGBImage(data_np=maskRGB) Image = self._canvas.get_draw_class( 'image') # the appropriate class maskImageRGBA = Image(0, 0, rgb_img) self._canvas.add(maskImageRGBA) # # Graphics commands # def _buffer(self, enable=True): self._redraw = not enable def _flush(self): self._viewer.redraw(whence=3) def _erase(self): """Erase the display""" self._canvas.delete_all_objects() def _dot(self, symb, c, r, size, ctype, fontFamily="helvetica", textAngle=None): """Draw a symbol at (col,row) = (c,r) [0-based coordinates] Possible values are: + Draw a + x Draw an x * Draw a * o Draw a circle @:Mxx,Mxy,Myy Draw an ellipse with moments (Mxx, Mxy, Myy) (argument size is ignored) An object derived from afwGeom.ellipses.BaseCore Draw the ellipse (argument size is ignored) Any other value is interpreted as a string to be drawn. Strings obey the fontFamily (which may be extended with other characteristics, e.g. "times bold italic". Text will be drawn rotated by textAngle (textAngle is ignored otherwise). N.b. objects derived from BaseCore include Axes and Quadrupole. """ if isinstance(symb, afwGeom.ellipses.BaseCore): Ellipse = self._canvas.get_draw_class('ellipse') self._canvas.add(Ellipse(c, r, xradius=symb.getA(), yradius=symb.getB(), rot_deg=math.degrees(symb.getTheta()), color=ctype), redraw=self._redraw) elif symb == 'o': Circle = self._canvas.get_draw_class('circle') self._canvas.add(Circle(c, r, radius=size, color=ctype), redraw=self._redraw) else: Line = self._canvas.get_draw_class('line') Text = self._canvas.get_draw_class('text') for ds9Cmd in ds9Regions.dot(symb, c, r, size, fontFamily="helvetica", textAngle=None): tmp = ds9Cmd.split('#') cmd = tmp.pop(0).split() comment = tmp.pop(0) if tmp else "" cmd, args = cmd[0], cmd[1:] if cmd == "line": self._canvas.add(Line(*[float(p) - 1 for p in args], color=ctype), redraw=self._redraw) elif cmd == "text": x, y = [float(p) - 1 for p in args[0:2]] self._canvas.add(Text(x, y, symb, color=ctype), redraw=self._redraw) else: raise RuntimeError(ds9Cmd) def _drawLines(self, points, ctype): """Connect the points, a list of (col,row) Ctype is the name of a colour (e.g. 'red') """ Line = self._canvas.get_draw_class('line') p0 = points[0] for p in points[1:]: self._canvas.add(Line(p0[0], p0[1], p[0], p[1], color=ctype), redraw=self._redraw) p0 = p # # Set gray scale # def _scale(self, algorithm, min, max, unit, *args, **kwargs): self._viewer.set_color_map('gray') self._viewer.set_color_algorithm(algorithm) if min == "zscale": self._viewer.set_autocut_params('zscale', contrast=0.25) self._viewer.auto_levels() elif min == "minmax": self._viewer.set_autocut_params('minmax') self._viewer.auto_levels() else: if unit: print("ginga: ignoring scale unit %s" % unit, file=sys.stderr) self._viewer.cut_levels(min, max) def _show(self): """Show the requested display In this case, embed it in the notebook (equivalent to Display.get_viewer().show(); see also Display.get_viewer().embed() N.b. These command *must* be the last entry in their cell """ return self._viewer.show() # # Zoom and Pan # def _zoom(self, zoomfac): """Zoom by specified amount""" self._viewer.scale_to(zoomfac, zoomfac) def _pan(self, colc, rowc): """Pan to (colc, rowc)""" self._viewer.set_pan(colc, rowc) def XXX_getEvent(self): """Listen for a key press, returning (key, x, y)""" raise RuntimeError("Write me") k = '?' x, y = self._viewer.get_pan() return GingaEvent(k, x, y)