Exemplo n.º 1
0
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)
Exemplo n.º 2
0
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)
Exemplo n.º 3
0
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)