Source code for jdaviz.configs.imviz.helper

import os
import re
import sys
import warnings
from copy import deepcopy

import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.utils.introspection import minversion
from astropy.wcs import NoConvergence
from astropy.wcs.wcsapi import BaseHighLevelWCS
from echo import delay_callback
from glue.config import colormaps
from glue.core import BaseData, Data
from glue.core.subset import Subset, MaskSubsetState

from jdaviz.core.events import SnackbarMessage
from jdaviz.core.helpers import ConfigHelper

__all__ = ['Imviz']

ASTROPY_LT_4_3 = not minversion('astropy', '4.3')
RESERVED_MARKER_SET_NAMES = ['all']


[docs]class Imviz(ConfigHelper): """Imviz Helper class""" _default_configuration = 'imviz' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Markers self._marktags = set() self._default_mark_tag_name = 'default-marker-name' # marker shape not settable: https://github.com/glue-viz/glue/issues/2202 self.marker = {'color': 'red', 'alpha': 1.0, 'markersize': 5}
[docs] def load_data(self, data, parser_reference=None, **kwargs): """Load data into Imviz. Parameters ---------- data : obj or str File name or object to be loaded. Supported formats include: * ``'filename.fits'`` (or any extension that ``astropy.io.fits`` supports; first image extension found is loaded unless ``ext`` keyword is also given) * ``'filename.fits[SCI]'`` (loads only first SCI extension) * ``'filename.fits[SCI,2]'`` (loads the second SCI extension) * ``'filename.jpg'`` (requires ``scikit-image``; grayscale only) * ``'filename.png'`` (requires ``scikit-image``; grayscale only) * JWST ASDF-in-FITS file (requires ``asdf`` and ``gwcs``; ``data`` or given ``ext`` + GWCS) * ``astropy.io.fits.HDUList`` object (first image extension found is loaded unless ``ext`` keyword is also given) * ``astropy.io.fits.ImageHDU`` object * ``astropy.nddata.NDData`` object (2D only but may have unit, mask, or uncertainty attached) * Numpy array (2D only) parser_reference This is used internally by the app. kwargs : dict Extra keywords to be passed into app-level parser. The only one you might call directly here is ``ext`` (any FITS extension format supported by ``astropy.io.fits``) and ``show_in_viewer`` (bool). Notes ----- When loading image formats that support RGB color like JPG or PNG, the files are converted to greyscale. This is done following the algorithm of ``skimage.color.rgb2grey``, which involves weighting the channels as ``0.2125 R + 0.7154 G + 0.0721 B``. If you prefer a different weighting, you can use ``skimage.io.imread`` to produce your own greyscale image as Numpy array and load the latter instead. """ if isinstance(data, str): filelist = data.split(',') if len(filelist) > 1 and 'data_label' in kwargs: raise ValueError('Do not manually overwrite data_label for ' 'a list of images') for data in filelist: kw = deepcopy(kwargs) filepath, ext, data_label = split_filename_with_fits_ext(data) # This, if valid, will overwrite input. if ext is not None: kw['ext'] = ext # This will only overwrite if not provided. if 'data_label' not in kw: kw['data_label'] = data_label self.app.load_data( filepath, parser_reference=parser_reference, **kw) else: self.app.load_data( data, parser_reference=parser_reference, **kwargs)
[docs] def save(self, filename): """Save out the current image view to given PNG filename.""" if not filename.lower().endswith('.png'): filename = filename + '.png' viewer = self.app.get_viewer("viewer-1") viewer.figure.save_png(filename=filename)
[docs] def center_on(self, point): """Centers the view on a particular point. Parameters ---------- point : tuple or `~astropy.coordinates.SkyCoord` If tuple of ``(X, Y)`` is given, it is assumed to be in data coordinates and 0-indexed. Raises ------ AttributeError Sky coordinates are given but image does not have a valid WCS. """ viewer = self.app.get_viewer("viewer-1") i_top = get_top_layer_index(viewer) image = viewer.layers[i_top].layer if isinstance(point, SkyCoord): if data_has_valid_wcs(image): try: pix = image.coords.world_to_pixel(point) # 0-indexed X, Y except NoConvergence as e: # pragma: no cover self.app.hub.broadcast(SnackbarMessage( f'{point} is likely out of bounds: {repr(e)}', color="warning", sender=self.app)) return else: raise AttributeError(f'{getattr(image, "label", None)} does not have a valid WCS') else: pix = point # Disallow centering outside of display; image.shape is (Y, X) eps = sys.float_info.epsilon if (not np.all(np.isfinite(pix)) or pix[0] < -eps or pix[0] >= (image.shape[1] + eps) or pix[1] < -eps or pix[1] >= (image.shape[0] + eps)): self.app.hub.broadcast(SnackbarMessage( f'{pix} is out of bounds', color="warning", sender=self.app)) return with delay_callback(viewer.state, 'x_min', 'x_max', 'y_min', 'y_max'): width = viewer.state.x_max - viewer.state.x_min height = viewer.state.y_max - viewer.state.y_min viewer.state.x_min = pix[0] - (width * 0.5) viewer.state.y_min = pix[1] - (height * 0.5) viewer.state.x_max = viewer.state.x_min + width viewer.state.y_max = viewer.state.y_min + height
[docs] def offset_by(self, dx, dy): """Move the center to a point that is given offset away from the current center. Parameters ---------- dx, dy : float or `~astropy.units.Quantity` Offset value. Without a unit, assumed to be pixel offsets. If a unit is attached, offset by pixel or sky is assumed from the unit. Raises ------ AttributeError Sky offset is given but image does not have a valid WCS. ValueError Offsets are of different types. astropy.units.core.UnitTypeError Sky offset has invalid unit. """ viewer = self.app.get_viewer("viewer-1") width = viewer.state.x_max - viewer.state.x_min height = viewer.state.y_max - viewer.state.y_min dx, dx_coord = _offset_is_pixel_or_sky(dx) dy, dy_coord = _offset_is_pixel_or_sky(dy) if dx_coord != dy_coord: raise ValueError(f'dx is of type {dx_coord} but dy is of type {dy_coord}') if dx_coord == 'wcs': i_top = get_top_layer_index(viewer) image = viewer.layers[i_top].layer if data_has_valid_wcs(image): # To avoid distortion headache, assume offset is relative to # displayed center. x_cen = viewer.state.x_min + (width * 0.5) y_cen = viewer.state.y_min + (height * 0.5) sky_cen = image.coords.pixel_to_world(x_cen, y_cen) if ASTROPY_LT_4_3: from astropy.coordinates import SkyOffsetFrame new_sky_cen = sky_cen.__class__( SkyOffsetFrame(dx, dy, origin=sky_cen.frame).transform_to(sky_cen)) else: new_sky_cen = sky_cen.spherical_offsets_by(dx, dy) self.center_on(new_sky_cen) else: raise AttributeError(f'{getattr(image, "label", None)} does not have a valid WCS') else: with delay_callback(viewer.state, 'x_min', 'x_max', 'y_min', 'y_max'): viewer.state.x_min += dx viewer.state.y_min += dy viewer.state.x_max = viewer.state.x_min + width viewer.state.y_max = viewer.state.y_min + height
@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. * 'fit' means zoomed to fit the whole image width into display. """ viewer = self.app.get_viewer("viewer-1") if viewer.shape is None: # pragma: no cover raise ValueError('Viewer is still loading, try again later') screenx = viewer.shape[1] screeny = viewer.shape[0] zoom_x = screenx / (viewer.state.x_max - viewer.state.x_min) zoom_y = screeny / (viewer.state.y_max - viewer.state.y_min) return max(zoom_x, zoom_y) # Similar to Ginga get_scale() # Loosely based on glue/viewers/image/state.py @zoom_level.setter def zoom_level(self, val): if ((not isinstance(val, (int, float)) and val != 'fit') or (isinstance(val, (int, float)) and val <= 0)): raise ValueError(f'Unsupported zoom level: {val}') viewer = self.app.get_viewer("viewer-1") image = viewer.state.reference_data if (image is None or viewer.shape is None or viewer.state.x_att is None or viewer.state.y_att is None): # pragma: no cover return # Zoom on X and Y will auto-adjust. if val == 'fit': # Similar to ImageViewerState.reset_limits() in Glue. new_x_min = 0 new_x_max = image.shape[viewer.state.x_att.axis] else: cur_xcen = (viewer.state.x_min + viewer.state.x_max) * 0.5 new_dx = viewer.shape[1] * 0.5 / val new_x_min = cur_xcen - new_dx new_x_max = cur_xcen + new_dx with delay_callback(viewer.state, 'x_min', 'x_max'): viewer.state.x_min = new_x_min - 0.5 viewer.state.x_max = new_x_max - 0.5 # We need to adjust the limits in here to avoid triggering all # the update events then changing the limits again. viewer.state._adjust_limits_aspect() # Discussion on why we need two different ways to set zoom at # https://github.com/astropy/astrowidgets/issues/144
[docs] def zoom(self, val): """Zoom in or out by the given factor. Parameters ---------- val : int or float The zoom level to zoom the image. See `zoom_level`. Raises ------ ValueError Invalid zoom factor. """ if not isinstance(val, (int, float)): raise ValueError(f"zoom only accepts int or float but got '{val}'") self.zoom_level = self.zoom_level * val
@property def colormap_options(self): """List of colormap names.""" return sorted(member[1].name for member in colormaps.members)
[docs] def set_colormap(self, cmap): """Set colormap to the given colormap name. Parameters ---------- cmap : str Colormap name. Possible values can be obtained from :meth:`colormap_options`. Raises ------ ValueError Invalid colormap name. """ cm = None for member in colormaps.members: if member[1].name == cmap: cm = member[1] break if cm is None: raise ValueError(f"Invalid colormap '{cmap}', must be one of {self.colormap_options}") viewer = self.app.get_viewer("viewer-1") i_top = get_top_layer_index(viewer) viewer.state.layers[i_top].cmap = cm
@property def stretch_options(self): """List of all available options for image stretching. Their ``astropy.visualization`` counterparts are also accepted, as follows: * ``'arcsinh'``: ``astropy.visualization.AsinhStretch`` * ``'linear'``: ``astropy.visualization.LinearStretch`` * ``'log'``: ``astropy.visualization.LogStretch`` * ``'sqrt'``: ``astropy.visualization.SqrtStretch`` """ # TODO: Is there a better way to access this in Glue? See glue/viewers/image/state.py return ['arcsinh', 'linear', 'log', 'sqrt'] @property def stretch(self): """The image stretching algorithm in use.""" viewer = self.app.get_viewer("viewer-1") i_top = get_top_layer_index(viewer) return viewer.state.layers[i_top].stretch @stretch.setter def stretch(self, val): valid_vals = self.stretch_options if isinstance(val, type): # is a class # Translate astropy.visualization from astropy.visualization import AsinhStretch, LinearStretch, LogStretch, SqrtStretch if issubclass(val, AsinhStretch): val = 'arcsinh' elif issubclass(val, LinearStretch): val = 'linear' elif issubclass(val, LogStretch): val = 'log' elif issubclass(val, SqrtStretch): val = 'sqrt' else: raise ValueError(f"Invalid stretch {val}, must be one of {valid_vals}") elif val not in valid_vals: raise ValueError(f"Invalid stretch '{val}', must be one of {valid_vals}") viewer = self.app.get_viewer("viewer-1") i_top = get_top_layer_index(viewer) viewer.state.layers[i_top].stretch = val @property def autocut_options(self): """List of all available options for automatic image cut levels.""" # See glue-jupyter/bqplot/image/state.py#L29 return ['minmax', '99.5%', '99%', '95%', '90%'] @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`. """ viewer = self.app.get_viewer("viewer-1") i_top = get_top_layer_index(viewer) return viewer.state.layers[i_top].v_min, viewer.state.layers[i_top].v_max # TODO: Support astropy.visualization, see https://github.com/glue-viz/glue/issues/2218 @cuts.setter def cuts(self, val): viewer = self.app.get_viewer("viewer-1") i_top = get_top_layer_index(viewer) if isinstance(val, str): # autocut if val == 'minmax': val = 100 elif val == '99.5%': val = 99.5 elif val == '99%': val = 99 elif val == '95%': val = 95 elif val == '90%': val = 90 else: raise ValueError(f"Invalid autocut '{val}', must be one of {self.autocut_options}") viewer.state.layers[i_top].percentile = val else: # (low, high) if (not isinstance(val, (list, tuple)) or len(val) != 2 or not np.all([isinstance(x, (int, float)) for x in val])): raise ValueError(f"Invalid cut levels {val}, must be (low, high)") viewer.state.layers[i_top].v_min = val[0] viewer.state.layers[i_top].v_max = val[1] @property def marker(self): """Marker to use. Marker can be set as follows; e.g.:: {'color': 'red', 'alpha': 1.0, 'markersize': 3} {'color': '#ff0000', 'alpha': 0.5, 'markersize': 10} {'color': (1, 0, 0)} The valid properties for markers in imviz are listed at https://docs.glueviz.org/en/stable/api/glue.core.visual.VisualAttributes.html """ return self._marker_dict @marker.setter def marker(self, val): # Validation: Ideally Glue should do this but we have to due to # https://github.com/glue-viz/glue/issues/2203 given = set(val.keys()) allowed = set(('color', 'alpha', 'markersize')) if not given.issubset(allowed): raise KeyError(f'Invalid attribute(s): {given - allowed}') if 'color' in val: from matplotlib.colors import ColorConverter ColorConverter().to_rgb(val['color']) # ValueError: Invalid RGBA argument if 'alpha' in val: alpha = val['alpha'] if not isinstance(alpha, (int, float)) or alpha < 0 or alpha > 1: raise ValueError(f'Invalid alpha: {alpha}') if 'markersize' in val: size = val['markersize'] if not isinstance(size, (int, float)): raise ValueError(f'Invalid marker size: {size}') # Only set this once we have successfully validated a marker. # Those not set here use Glue defaults. self._marker_dict = val 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( f"The marker name {marker_name} is not allowed. Any name is " f"allowed except these: {', '.join(RESERVED_MARKER_SET_NAMES)}")
[docs] def add_markers(self, table, x_colname='x', y_colname='y', skycoord_colname='coord', use_skycoord=False, marker_name=None): """Creates markers w.r.t. the reference image at given points in the table. .. note:: Use `marker` to change marker appearance. Parameters ---------- table : `~astropy.table.Table` Table containing marker locations. x_colname, y_colname : str Column names for X and Y. Coordinates must be 0-indexed. skycoord_colname : str Column name with `~astropy.coordinates.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. Raises ------ AttributeError Sky coordinates are given but reference image does not have a valid WCS. ValueError Invalid marker name. """ if marker_name is None: marker_name = self._default_mark_tag_name self._validate_marker_name(marker_name) viewer = self.app.get_viewer("viewer-1") jglue = self.app.session.application # TODO: How to link to invidual images separately for X/Y? add_link in loop does not work. # Link markers to reference image data. image = viewer.state.reference_data # TODO: Is Glue smart enough to no-op if link already there? if use_skycoord: if not data_has_valid_wcs(image): raise AttributeError(f'{getattr(image, "label", None)} does not have a valid WCS') sky = table[skycoord_colname] t_glue = Data(marker_name, ra=sky.ra.deg, dec=sky.dec.deg) jglue.data_collection[marker_name] = t_glue jglue.add_link(t_glue, 'ra', image, 'Right Ascension') jglue.add_link(t_glue, 'dec', image, 'Declination') else: t_glue = Data(marker_name, **table[x_colname, y_colname]) jglue.data_collection[marker_name] = t_glue jglue.add_link(t_glue, x_colname, image, image.pixel_component_ids[1].label) jglue.add_link(t_glue, y_colname, image, image.pixel_component_ids[0].label) try: viewer.add_data(t_glue) except Exception as e: # pragma: no cover self.app.hub.broadcast(SnackbarMessage( f"Failed to add markers '{marker_name}': {repr(e)}", color="warning", sender=self.app)) else: # Only can set alpha and color using viewer.add_data(), so brute force here instead. # https://github.com/glue-viz/glue/issues/2201 for key, val in self.marker.items(): setattr(self.app.data_collection[self.app.data_collection.labels.index(marker_name)].style, key, val) # noqa self._marktags.add(marker_name)
[docs] def remove_markers(self, marker_name=None): """Remove some but not all of the markers by name used when adding the markers. Parameters ---------- marker_name : str Name used when the markers were added. If not given, will delete markers added under default name. """ if marker_name is None: marker_name = self._default_mark_tag_name try: i = self.app.data_collection.labels.index(marker_name) except ValueError as e: # pragma: no cover self.app.hub.broadcast(SnackbarMessage( f"Failed to remove markers '{marker_name}': {repr(e)}", color="warning", sender=self.app)) return data = self.app.data_collection[i] self.app.data_collection.remove(data) self._marktags.remove(marker_name)
[docs] def reset_markers(self): """Delete all markers.""" # Grab the entire list of marker names before iterating # otherwise what we are iterating over changes. for marker_name in list(self._marktags): self.remove_markers(marker_name=marker_name)
[docs] def load_static_regions(self, regions, **kwargs): """Load given region(s) into the viewer. Region(s) is relative to the reference image. Once loaded, the region(s) cannot be modified. Parameters ---------- regions : dict Dictionary mapping desired region name to one of the following: * Astropy ``regions`` object * ``photutils`` apertures (limited support until ``photutils`` fully supports ``regions``) * Numpy boolean array (shape must match data) Region name that starts with "Subset" is forbidden and reserved for internal use only. kwargs : dict Extra keywords to be passed into the region's ``to_mask`` method. This is ignored if Numpy array is given. """ viewer = self.app.get_viewer("viewer-1") data = viewer.state.reference_data for subset_label, region in regions.items(): if subset_label.startswith('Subset'): warnings.warn(f'{subset_label} is not allowed, skipping. ' 'Do not use region name that starts with Subset.') continue if hasattr(region, 'to_pixel'): if data_has_valid_wcs(data): pixreg = region.to_pixel(data.coords) mask = pixreg.to_mask(**kwargs) im = mask.to_image(data.shape) else: warnings.warn(f'{region} given but data has no valid WCS, skipping') continue elif hasattr(region, 'to_mask'): mask = region.to_mask(**kwargs) im = mask.to_image(data.shape) elif (isinstance(region, np.ndarray) and region.shape == data.shape and region.dtype == np.bool_): im = region else: warnings.warn(f'Unsupported region type: {type(region)}, skipping') continue # NOTE: Region creation info is thus lost. state = MaskSubsetState(im, data.pixel_component_ids) self.app.data_collection.new_subset_group(subset_label, state)
[docs] def get_interactive_regions(self): """Return regions interactively drawn in the viewer. This does not return regions added via :meth:`load_static_regions`. Returns ------- regions : dict Dictionary mapping interactive region names to respective Astropy ``regions`` objects. """ regions = {} viewer = self.app.get_viewer("viewer-1") for lyr in viewer.layers: if (not hasattr(lyr, 'layer') or not isinstance(lyr.layer, Subset) or lyr.layer.ndim != 2): continue subset_data = lyr.layer subset_label = subset_data.label # TODO: Remove this when Imviz support round-tripping, see # https://github.com/spacetelescope/jdaviz/pull/721 if not subset_label.startswith('Subset'): continue region = subset_data.data.get_selection_definition( subset_id=subset_label, format='astropy-regions') regions[subset_label] = region return regions
# See https://github.com/glue-viz/glue-jupyter/issues/253 def _apply_interactive_region(self, toolname, from_pix, to_pix): """Mimic interactive region drawing. This is for internal testing only. """ viewer = self.app.get_viewer("viewer-1") tool = viewer.toolbar.tools[toolname] tool.activate() tool.interact.brushing = True tool.interact.selected = [from_pix, to_pix] tool.interact.brushing = False
def split_filename_with_fits_ext(filename): """Split a ``filename[ext]`` input into filename and FITS extension. Parameters ---------- filename : str Can be a plain filename or ``filename[ext]``. The latter is a form of input that is commonly used by DS9. Example values: * ``'myimage.fits'`` * ``'myimage.fits[SCI]'`` (assumes ``EXTVER=1``) * ``'myimage.fits[SCI,1]'`` Returns ------- filepath : str Path to the file, without extension. ext : str, tuple, or `None` FITS extension, if given. Examples: ``'SCI'`` or ``('SCI', 1)`` data_label : str Human-readable data label for Glue. Extension info will be added later in the parser. """ s = os.path.splitext(filename) ext_match = re.match(r'(.+)\[(.+)\]', s[1]) if ext_match is None: sfx = s[1] ext = None else: sfx = ext_match.group(1) ext = ext_match.group(2) if ',' in ext: ext = ext.split(',') ext[1] = int(ext[1]) ext = tuple(ext) elif not re.match(r'\D+', ext): ext = int(ext) filepath = f'{s[0]}{sfx}' data_label = os.path.basename(s[0]) return filepath, ext, data_label def data_has_valid_wcs(data): return hasattr(data, 'coords') and isinstance(data.coords, BaseHighLevelWCS) def layer_is_image_data(layer): return isinstance(layer, BaseData) and layer.ndim == 2 def get_top_layer_index(viewer): """Get index of the top visible image layer in Imviz. This is because when blinked, first layer might not be top visible layer. """ return [i for i, lyr in enumerate(viewer.layers) if lyr.visible and layer_is_image_data(lyr.layer)][-1] def _offset_is_pixel_or_sky(x): if isinstance(x, u.Quantity): if x.unit in (u.dimensionless_unscaled, u.pix): coord = 'data' val = x.value else: coord = 'wcs' val = x # Can stay Quantity else: coord = 'data' val = x return val, coord