From e695e1cba39b0b1feada4f513148a5eaab5ebb3a Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sat, 27 Jun 2026 12:52:19 -0500 Subject: [PATCH 01/14] Rewrite ginga backend as an AIDA-compliant widget Bring the ginga backend up to the same astro-image-display-api (AIDA) standard as the bqplot backend. The widget now inherits ImageViewerLogic and exposes only the AIDA API publicly, plus the interactive extras that ginga supports natively. - Replace the broken ImageViewerInterface.* class attributes (which no longer exist and prevented import) with plain literals. - load_image stores data/WCS via super(), builds a ginga AstroImage, and re-applies the stored viewport, cuts, stretch and colormap. - set_cuts/set_stretch map astropy objects onto ginga cut levels and color algorithms without disturbing the viewport; set_colormap maps the name. - Catalog API draws on the ginga canvas under a tag = str(catalog_label). - Viewport API converts the pixel field of view to a ginga scale as scale = window / fov; get_viewport syncs live ginga pan/zoom into the stored viewport (only when the user actually interacted, keeping programmatic round-trips exact). - Keep interactive extras: cursor, click_center, click_drag, scroll_pan, print_out, image_width/height, and interactive marking wired to the catalog API. - Drop the ginga-native public methods (center_on, offset_by, zoom, add_markers, marker, cuts/stretch properties, ...). - Fix the save() existence check (Path(filename).exists()). Passes the AIDA compliance suite (56 passed, 2 save tests skipped), matching the bqplot backend. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 1146 +++++++----------- astrowidgets/tests/_test_widget_api_ginga.py | 18 - astrowidgets/tests/test_widget_api_ginga.py | 31 + 3 files changed, 460 insertions(+), 735 deletions(-) delete mode 100644 astrowidgets/tests/_test_widget_api_ginga.py create mode 100644 astrowidgets/tests/test_widget_api_ginga.py diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 8a0c66c..952289a 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -1,4 +1,4 @@ -"""Module containing core functionality of ``astrowidgets``.""" +"""AIDA-compliant image widget for Jupyter notebooks using the Ginga viewer.""" # STDLIB import functools @@ -7,11 +7,9 @@ # THIRD-PARTY import numpy as np -from astropy import units as u from astropy.coordinates import SkyCoord -from astropy.io import fits -from astropy.table import Table, vstack -from astropy.utils.decorators import deprecated +from astropy.nddata import NDData +from astropy.table import Table # Jupyter widgets import ipywidgets as ipyw @@ -22,16 +20,49 @@ from ginga.web.jupyterw.ImageViewJpw import EnhancedCanvasView from ginga.util.wcs import ra_deg_to_str, dec_deg_to_str -from astro_image_display_api import ImageViewerInterface +from astro_image_display_api.image_viewer_logic import ImageViewerLogic __all__ = ['ImageWidget'] -class ImageWidget(ipyw.VBox): +# Map the astropy.visualization stretch classes to the names ginga uses for +# its color-distribution ("stretch") algorithms. +_ASTROPY_STRETCH_TO_GINGA = { + 'LinearStretch': 'linear', + 'LogStretch': 'log', + 'SqrtStretch': 'sqrt', + 'PowerStretch': 'power', + 'AsinhStretch': 'asinh', + 'SinhStretch': 'sinh', + 'SquaredStretch': 'squared', +} + + +def docs_from_super_if_missing(cls): + """ + Decorator to copy the docstrings from the interface methods to the + methods in the class. """ - Image widget for Jupyter notebook using Ginga viewer. + for name, method in cls.__dict__.items(): + if not name.startswith("_"): + if method.__doc__: + continue + interface_method = getattr(ImageViewerLogic, name, None) + if interface_method: + method.__doc__ = interface_method.__doc__ + return cls + + +# The inheritance order below matters -- VBox needs to come first +@docs_from_super_if_missing +class ImageWidget(ipyw.VBox, ImageViewerLogic): + """ + Image widget for Jupyter notebook using the Ginga viewer. - .. todo:: Any property passed to constructor has to be valid keyword. + This widget implements the astro-image-display-api (AIDA) + `~astro_image_display_api.ImageViewerInterface`, plus a handful of + interactive conveniences that Ginga supports natively (live cursor + readout, click-to-center, and interactive marking). Parameters ---------- @@ -43,30 +74,33 @@ class ImageWidget(ipyw.VBox): log_file='ginga.log', level=40) image_width, image_height : int - Dimension of Jupyter notebook's image widget. + Dimension of the Jupyter notebook's image widget, in pixels. 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.* - """ # Allowed locations for cursor display - ALLOWED_CURSOR_LOCATIONS = ImageViewerInterface.ALLOWED_CURSOR_LOCATIONS + ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] # List of marker names that are for internal use only - RESERVED_MARKER_SET_NAMES = ImageViewerInterface.RESERVED_MARKER_SET_NAMES + RESERVED_MARKER_SET_NAMES = ['all'] # Default marker name for marking via API - DEFAULT_MARKER_NAME: str = ImageViewerInterface.DEFAULT_MARKER_NAME + DEFAULT_MARKER_NAME: str = 'default' # Default marker name for interactive marking - DEFAULT_INTERACTIVE_MARKER_NAME: str = ImageViewerInterface.DEFAULT_INTERACTIVE_MARKER_NAME + DEFAULT_INTERACTIVE_MARKER_NAME: str = 'interactive' - def __init__(self, logger=None, image_width=500, image_height=500, + def __init__(self, *args, logger=None, image_width=500, image_height=500, pixel_coords_offset=0, **kwargs): - super().__init__() + super().__init__(*args) + # ImageViewerLogic is a dataclass; we do not run its __init__, so set + # up the state it would otherwise provide by hand. + self._set_up_catalog_image_dicts() + self._wcs = None if 'use_opencv' in kwargs: warnings.warn("use_opencv kwarg has been deprecated--" @@ -79,25 +113,19 @@ def __init__(self, logger=None, image_width=500, image_height=500, self._jup_img = ipyw.Image(format='jpeg') - # Set the image margin to over the widgets default of 2px on - # all sides. + # Set the image margin over the widget's 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. - + # Set both of these to ensure consistent display in the notebook and in + # jupyterlab when the image is put into a container smaller than itself. self._jup_img.max_width = '100%' self._jup_img.height = 'auto' - # Set the width of the box containing the image to the desired width + # Set the width of the box containing the image to the desired width. + # The height is set automatically by the image aspect ratio. 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. + # Ginga uses these to figure out what size image to make. self._jup_img.width = image_width self._jup_img.height = image_height @@ -106,39 +134,36 @@ def __init__(self, logger=None, image_width=500, image_height=500, # enable all possible keyboard and pointer operations self._viewer.get_bindings().enable_all(True) - # enable draw + # enable drawing on the canvas (used for catalog markers) 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. + # Internal interaction-state trackers; all start disabled. 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 + # 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 + # Right-click and drag adjusts the contrast; shift-right-click restores. 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 = ImageViewerInterface.DEFAULT_MARKER_NAME - self._interactive_marker_set_name_default = ImageViewerInterface.DEFAULT_INTERACTIVE_MARKER_NAME - self._interactive_marker_set_name = self._interactive_marker_set_name_default + # State for interactive marking. + self._interactive_marker_set_name = self.DEFAULT_INTERACTIVE_MARKER_NAME + self._interactive_points = [] + self._interactive_style = self._default_catalog_style.copy() + + # Guards re-entrancy while we programmatically update the viewport so + # that the live-state sync in get_viewport does not clobber the values + # we just set. + self._updating_viewport = False # coordinates display self._jup_coord = ipyw.HTML('Coordinates show up here') @@ -146,36 +171,44 @@ def __init__(self, logger=None, image_width=500, image_height=500, 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() + # Output widget that captures printed output, for debugging. + self._print_out = ipyw.Output() self._cursor = 'bottom' self.children = [self._jup_img, self._jup_coord] + # ------------------------------------------------------------------ + # Read-only conveniences + # ------------------------------------------------------------------ @property def logger(self): """Logger for this widget.""" return self._viewer.logger + @property + def viewer(self): + """The underlying ginga viewer object.""" + return self._viewer + @property def image_width(self): + """Width of the image widget, in pixels.""" 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. + # Widgets expect width/height as strings, but most users will not, so + # do the conversion here. self._jup_img.width = str(value) self._viewer.set_window_size(self.image_width, self.image_height) @property def image_height(self): + """Height of the image widget, in pixels.""" 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) @@ -191,9 +224,20 @@ def pixel_offset(self): """ return self._pixel_offset + @property + def print_out(self): + """ + An `ipywidgets.Output` widget that captures printed output from the + viewer, intended primarily for debugging. + """ + return self._print_out + + # ------------------------------------------------------------------ + # Mouse callbacks + # ------------------------------------------------------------------ def _mouse_move_cb(self, viewer, button, data_x, data_y): """ - Callback to display position in RA/DEC deg. + Callback to display the cursor position (and value) in the readout. """ if self.cursor is None: # no-op return @@ -223,682 +267,291 @@ def _mouse_move_cb(self, viewer, button, data_x, data_y): def _mouse_click_cb(self, viewer, event, data_x, data_y): """ - Callback to handle mouse clicks. + Callback to handle mouse clicks for marking or centering. """ if self.is_marking: - marker_name = self._interactive_marker_set_name - objs = [] - try: - c_mark = viewer.canvas.get_object_by_tag(marker_name) - except Exception: # Nothing drawn yet - pass - else: # Add to existing marks - objs = c_mark.objects - viewer.canvas.delete_object_by_tag(marker_name) - - # NOTE: By always using CompoundObject, marker handling logic - # is simplified. - obj = self._marker(x=data_x, y=data_y, coord='data') - objs.append(obj) - viewer.canvas.add(self.dc.CompoundObject(*objs), - tag=marker_name) - self._marktags.add(marker_name) - with self.print_out: - print('Selected {} {}'.format(obj.x, obj.y)) - + self._append_interactive_marker(data_x, data_y) + with self._print_out: + print('Selected {} {}'.format(data_x, data_y)) elif self.click_center: - self.center_on((data_x, data_y)) - - with self.print_out: + 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. + # ------------------------------------------------------------------ + # Image loading + # ------------------------------------------------------------------ + def load_image(self, image, image_label=None, **kwargs): + # Let the AIDA logic store the data + WCS and set up the initial + # viewport, cuts and stretch in our internal state. + super().load_image(image, image_label=image_label, **kwargs) + image_label = self._resolve_image_label(image_label) - """ - 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) + # Build a ginga AstroImage from the stored data and show it. + data = self.get_image(image_label=image_label) + self._viewer.set_image(self._build_ginga_image(data)) - elif isinstance(fitsorfn, (fits.ImageHDU, fits.CompImageHDU, - fits.PrimaryHDU)): - self._viewer.load_hdu(fitsorfn) + # Apply the stored viewport, cuts, stretch and colormap to the freshly + # displayed image. + self._apply_viewport_to_ginga(image_label) + self._apply_cuts_to_ginga(image_label) + self._apply_stretch_to_ginga(image_label) + self._apply_colormap_to_ginga(image_label) - def load_nddata(self, nddata): + def _build_ginga_image(self, data): """ - Load an ``NDData`` object into the viewer. - - .. todo:: Add flag/masking support, etc. - - Parameters - ---------- - nddata : `~astropy.nddata.NDData` - ``NDData`` with image data and WCS. - + Build a ginga `~ginga.AstroImage.AstroImage` from stored image data, + carrying over the WCS when one is available. """ - 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') + if isinstance(data, NDData): + image.set_data(np.asarray(data.data)) + wcs = getattr(data, 'wcs', None) + if wcs is not None: + from ginga.util.wcsmod.wcs_astropy import AstropyWCS + _wcs = AstropyWCS(self.logger) + _wcs.load_header(wcs.to_header()) + try: + image.set_wcs(_wcs) + except Exception as e: # pragma: no cover - defensive + with self._print_out: + print('Unable to set WCS from image: {}'.format(e)) else: - self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset)) - - @deprecated('0.3', alternative='offset_by') - def offset_to(self, dx, dy, skycoord_offset=False): - """ - Move the center to a point that is given offset - away from the current center. - - .. note:: This is deprecated. Use :meth:`offset_by`. - - Parameters - ---------- - dx, dy : float - Offset value. Unit is assumed based on - ``skycoord_offset``. + image.set_data(np.asarray(data)) + + return image + + # ------------------------------------------------------------------ + # Cuts / stretch / colormap + # ------------------------------------------------------------------ + def set_cuts(self, value, image_label=None, **kwargs): + super().set_cuts(value, image_label=image_label, **kwargs) + # Changing the cuts only affects the color mapping, so leave the + # current viewport (zoom/pan) untouched. + self._apply_cuts_to_ginga(image_label) + + def _apply_cuts_to_ginga(self, image_label): + ginga_image = self._viewer.get_image() + if ginga_image is None: + return + cuts = self.get_cuts(image_label=image_label) + low, high = cuts.get_limits(ginga_image.get_data()) + self._viewer.cut_levels(low, high) + + def set_stretch(self, value, image_label=None, **kwargs): + super().set_stretch(value, image_label=image_label, **kwargs) + # Changing the stretch only affects the color mapping, so leave the + # current viewport (zoom/pan) untouched. + self._apply_stretch_to_ginga(image_label) + + def _apply_stretch_to_ginga(self, image_label): + if self._viewer.get_image() is None: + return + stretch = self.get_stretch(image_label=image_label) + algorithm = _ASTROPY_STRETCH_TO_GINGA.get(type(stretch).__name__, 'linear') + self._viewer.set_color_algorithm(algorithm) - skycoord_offset : bool - If `True`, offset must be given in degrees. - Otherwise, they are in pixel values. + def set_colormap(self, map_name, image_label=None, **kwargs): + super().set_colormap(map_name, image_label=image_label, **kwargs) + self._apply_colormap_to_ginga(image_label) - """ - if skycoord_offset: - coord = 'wcs' + def _apply_colormap_to_ginga(self, image_label): + if self._viewer.get_image() is None: + return + cmap_name = self.get_colormap(image_label=image_label) + if cmap_name is not None: + self._viewer.set_color_map(cmap_name) + + # ------------------------------------------------------------------ + # Catalog API + # ------------------------------------------------------------------ + def load_catalog(self, table, **kwargs): + super().load_catalog(table, **kwargs) + catalog_label = kwargs.get("catalog_label", None) + self._draw_catalog(catalog_label) + + def set_catalog_style(self, catalog_label=None, shape="circle", + color="red", size=5, **kwargs): + super().set_catalog_style( + catalog_label=catalog_label, shape=shape, color=color, size=size, + **kwargs + ) + self._draw_catalog(catalog_label) + + def remove_catalog(self, catalog_label=None, **kwargs): + # Figure out which canvas tags to remove before the bookkeeping in + # super() forgets about them. + if catalog_label == "*": + tags = [str(label) for label in self._catalogs] else: - coord = 'data' + tags = [str(self._resolve_catalog_label(catalog_label))] - pan_x, pan_y = self._viewer.get_pan(coord=coord) - self._viewer.set_pan(pan_x + dx, pan_y + dy, coord=coord) - - def offset_by(self, dx, dy): - """ - Move the center to a point that is given offset - away from the current center. + super().remove_catalog(catalog_label, **kwargs) - Parameters - ---------- - dx, dy : float or `~astropy.unit.Quantity` - Offset value. Without a unit, assumed to be pixel offsets. - If a unit is attached, offset by pixel or sky is assumed from - the unit. + for tag in tags: + try: + self._viewer.canvas.delete_object_by_tag(tag) + except Exception: + # Nothing was drawn under this tag; that is fine. + pass + def _make_marker(self, style): """ - dx_val, dx_coord = _offset_is_pixel_or_sky(dx) - dy_val, dy_coord = _offset_is_pixel_or_sky(dy) - - if dx_coord != dy_coord: - raise u.UnitConversionError(f'dx is of type {dx_coord} but dy is of type {dy_coord} and they are not convertible.') - - pan_x, pan_y = self._viewer.get_pan(coord=dx_coord) - self._viewer.set_pan(pan_x + dx_val, pan_y + dy_val, coord=dx_coord) - - @property - def zoom_level(self): + Build a ginga drawing-object factory from a catalog style dict. """ - Zoom level: + shape = style.get('shape', 'circle') + color = style.get('color', 'red') + size = style.get('size', 5) - * 1 means real-pixel-size. - * 2 means zoomed in by a factor of 2. - * 0.5 means zoomed out by a factor of 2. + if shape in ('square', 'box'): + return functools.partial(self.dc.SquareBox, radius=size, color=color) + elif shape in ('cross', 'plus', 'crosshair'): + point_style = 'plus' if shape == 'plus' else 'cross' + return functools.partial(self.dc.Point, radius=size, style=point_style, + color=color) + else: # circle and anything we do not specifically handle + return functools.partial(self.dc.Circle, radius=size, color=color) + def _draw_catalog(self, catalog_label): """ - 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): + Draw (or redraw) a catalog's markers on the ginga canvas under a tag + derived from the catalog label. """ - Zoom in or out by the given factor. + tag = str(self._resolve_catalog_label(catalog_label)) - Parameters - ---------- - val : int - The zoom level to zoom the image. - See `zoom_level`. - - """ - self.zoom_level = self.zoom_level * val + # Remove any existing drawing for this catalog before redrawing. + try: + self._viewer.canvas.delete_object_by_tag(tag) + except Exception: + pass - @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 + catalog = self.get_catalog(catalog_label=catalog_label) + if catalog is None or len(catalog) == 0: + return - 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 + marker = self._make_marker(self.get_catalog_style(catalog_label=catalog_label)) - def stop_marking(self, clear_markers=False): - """ - Stop marking mode, with option to clear markers, if desired. + objs = [] + for x, y in zip(catalog['x'], catalog['y']): + if x is None or y is None or np.ma.is_masked(x) or np.ma.is_masked(y): + continue + objs.append(marker(x=float(x), y=float(y), coord='data')) - 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() + if objs: + self._viewer.canvas.add(self.dc.CompoundObject(*objs), tag=tag) + # ------------------------------------------------------------------ + # Viewport API + # ------------------------------------------------------------------ @property - def marker(self): + def _viewport_window_size(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): + The window dimension (in screen pixels) used to convert between a + pixel field of view and a ginga scale. The viewer is square in the + default configuration, so either dimension works; use the width. """ - 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. + return self._viewer.get_window_size()[0] - Returns - ------- - markers_table : `~astropy.table.Table` or ``None`` - Table of markers, if any, or ``None``. + def set_viewport(self, center=None, fov=None, image_label=None, **kwargs): + # The AIDA logic handles all of the WCS bookkeeping (and validation), + # which we need in case center/fov are in sky coordinates. + super().set_viewport(center=center, fov=fov, image_label=image_label, + **kwargs) + self._apply_viewport_to_ginga(image_label) + def _apply_viewport_to_ginga(self, image_label): """ - default_column_names = [x_colname, y_colname, skycoord_colname, 'marker name'] - - empty_table = Table(names=default_column_names) - - if marker_name is None: - marker_name = self._default_mark_tag_name - - # If it isn't a string assume it is a list of strings - if not isinstance(marker_name, str): - return vstack([self.get_markers(x_colname=x_colname, - y_colname=y_colname, - skycoord_colname=skycoord_colname, - marker_name=name) - for name in marker_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 len(table) == 0: - # 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) - - if len(tables) == 0: - # No markers at all, return an empty table - return empty_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): - return empty_table - - try: - c_mark = self._viewer.canvas.get_object_by_tag(marker_name) - except Exception: # Keep this broad -- unclear what exceptions can be raised - # No markers in this table. - return empty_table - - 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 self.RESERVED_MARKER_SET_NAMES: - raise ValueError('The marker name {} is not allowed. Any name is ' - 'allowed except these: ' - '{}'.format(marker_name, - ', '.join(self.RESERVED_MARKER_SET_NAMES))) - - def add_markers(self, table, x_colname='x', y_colname='y', - skycoord_colname='coord', use_skycoord=False, - marker_name=None): + Push the stored viewport (center + fov) into the ginga viewer as a pan + position and scale. """ - 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. + if self._viewer.get_image() is None: + return - use_skycoord : bool - If `True`, use ``skycoord_colname`` to mark. - Otherwise, use ``x_colname`` and ``y_colname``. + # Read the stored viewport back in pixel coordinates without going + # through our own get_viewport (which would try to sync ginga state). + viewport = super().get_viewport(sky_or_pixel='pixel', + image_label=image_label) + center_x, center_y = viewport['center'] + fov_pixels = viewport['fov'] - 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 = [] + self._updating_viewport = True 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): + self._viewer.set_pan(center_x, center_y) + if fov_pixels: + scale = self._viewport_window_size / fov_pixels + self._viewer.scale_to(scale, scale) + finally: + self._updating_viewport = False + + def get_viewport(self, sky_or_pixel=None, image_label=None, **kwargs): + # Reflect any interactive pan/zoom the user did in the browser by + # syncing the live ginga state into our stored viewport before + # returning it. This mirrors the bqplot backend, which reflects + # interactive viewport changes in get_viewport. + if not self._updating_viewport: + self._sync_ginga_to_stored_viewport(image_label) + return super().get_viewport(sky_or_pixel, image_label, **kwargs) + + def _sync_ginga_to_stored_viewport(self, image_label): + """ + Update the stored viewport from the live ginga pan/scale, but only if + the user has actually panned or zoomed since we last set it. Skipping + the update when nothing has changed keeps programmatically-set values + exact (important for sky/pixel round-trips). """ - Remove some but not all of the markers by name used when - adding the markers - - Parameters - ---------- - - marker_name : str, or list of str, optional - Name used when the markers were added. The name "all" will - remove all markers. - """ - # 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 == "all": - all_markers = self._marktags.copy() - for marker in all_markers: - self.remove_markers(marker_name=marker) + try: + image_label = self._resolve_image_label(image_label) + except ValueError: + # Ambiguous or unknown label -- let super().get_viewport raise the + # appropriate error instead of masking it here. return - # If not a string assume, marker_name is a list - if not isinstance(marker_name, str): - for name in marker_name: - self.remove_markers(marker_name=name) + if image_label not in self._images or self._viewer.get_image() is None: return - 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))) + # What pan/scale does the currently-stored viewport correspond to? + stored = super().get_viewport(sky_or_pixel='pixel', + image_label=image_label) + exp_x, exp_y = stored['center'] + exp_fov = stored['fov'] + window = self._viewport_window_size + exp_scale = window / exp_fov if exp_fov else None + + cur_x, cur_y = self._viewer.get_pan() + cur_scale = self._viewer.get_scale() + + changed = ( + abs(cur_x - exp_x) > 1e-6 + or abs(cur_y - exp_y) > 1e-6 + or (exp_scale is not None and abs(cur_scale - exp_scale) > 1e-9) + ) + if not changed: + return + # The user interacted; store the new pixel center and field of view. + fov_pixels = window / cur_scale if cur_scale else exp_fov + self._updating_viewport = True 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) + super().set_viewport(center=(cur_x, cur_y), fov=fov_pixels, + image_label=image_label) + finally: + self._updating_viewport = False - def reset_markers(self): + # ------------------------------------------------------------------ + # Interactive extras: cursor, click-to-center, marking + # ------------------------------------------------------------------ + def _center_on(self, point): """ - 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): + Center the view on a point given either as a `~astropy.coordinates.SkyCoord` + or as a tuple of pixel ``(X, Y)`` coordinates. """ - The image stretching algorithm in use. - """ - return self._viewer.rgbmap.get_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) + if isinstance(point, SkyCoord): + self._viewer.set_pan(point.icrs.ra.deg, point.icrs.dec.deg, + coord='wcs') + else: + self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset)) @property def cursor(self): @@ -921,20 +574,15 @@ def cursor(self, val): else: self.layout.flex_flow = 'column' else: - raise ValueError('Invalid value {} for cursor.' - 'Valid values are: ' + raise ValueError('Invalid value {} for cursor. Valid values are: ' '{}'.format(val, self.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. + Settable. If `True`, clicking on the image centers the view on the + clicked point. If `False`, that interaction is disabled. """ return self._click_center @@ -950,16 +598,11 @@ def click_center(self, val): 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. + Settable. If `True`, click-and-drag pans the image. If `False`, it is + disabled. Automatically made `False` while marking is active. """ return self._click_drag @@ -974,7 +617,7 @@ def click_drag(self, value): self._click_drag = value bindmap = self._viewer.get_bindmap() if value: - # Only turn off click_center if click_drag is being set to True + # 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: @@ -983,9 +626,8 @@ def click_drag(self, value): @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. + Settable. If `True`, scrolling pans the image. If `False`, scrolling + (up/down) zooms the image in and out. """ return self._scroll_pan @@ -1001,37 +643,107 @@ def scroll_pan(self, value): else: bindmap.map_event(None, (), 'pa_pan', 'zoom') - def save(self, filename, overwrite=False): + @property + def is_marking(self): + """ + `True` if in interactive marking mode, `False` otherwise. While + marking, a mouse click adds a new marker to the interactive catalog. + """ + return self._is_marking + + def start_marking(self, marker_name=None, marker=None): """ - Save out the current image view to given PNG filename. + Begin interactive marking. + + Parameters + ---------- + marker_name : str, optional + Catalog label under which the interactively-placed markers are + stored. Retrieve them later with ``get_catalog(catalog_label=...)``. + + marker : dict, optional + Style for the interactive markers, e.g. + ``{'shape': 'circle', 'color': 'cyan', 'size': 20}``. + """ + 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 + # Ensure there is still a mouse way to pan. + self.scroll_pan = True + self._is_marking = True + self._interactive_points = [] + + if marker_name is not None: + self._validate_marker_name(marker_name) + self._interactive_marker_set_name = marker_name + else: + self._interactive_marker_set_name = self.DEFAULT_INTERACTIVE_MARKER_NAME + + if marker is not None: + self._interactive_style = marker + + def stop_marking(self, clear_markers=False): + """ + Stop interactive marking. + + Parameters + ---------- + clear_markers : bool, optional + If `True`, remove the interactively-placed markers. If `False` + (default), they are retained and can be retrieved with + ``get_catalog``. + """ + 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: + try: + self.remove_catalog( + catalog_label=self._interactive_marker_set_name) + except ValueError: + pass + self._interactive_points = [] + + def _validate_marker_name(self, marker_name): + """Raise an error if ``marker_name`` is reserved.""" + if marker_name in self.RESERVED_MARKER_SET_NAMES: + raise ValueError('The marker name {} is not allowed. Any name is ' + 'allowed except these: ' + '{}'.format(marker_name, + ', '.join(self.RESERVED_MARKER_SET_NAMES))) + + def _append_interactive_marker(self, x, y): + """Add a point to the interactive catalog and redraw it.""" + self._interactive_points.append((x, y)) + table = Table(rows=self._interactive_points, names=['x', 'y']) + self.load_catalog(table, + catalog_label=self._interactive_marker_set_name, + catalog_style=self._interactive_style) + + # ------------------------------------------------------------------ + # Saving + # ------------------------------------------------------------------ + def save(self, filename, overwrite=False, **kwargs): + """ + Save the current image view to the given PNG filename. Parameters ---------- filename : str or `os.PathLike` - Name of file to save to. + Name of the file to save to. overwrite : bool, optional - If `True`, overwrite existing file. + If `True`, overwrite an existing file. """ - # It turns out the image value is already in PNG format so we just - # to write that out to a file. - if not overwrite and Path.exists(filename): + # The widget value is already PNG-encoded, so just write it out. This + # requires a running browser to have populated the buffer. + if not overwrite and Path(filename).exists(): raise FileExistsError(f'File {filename} exists and overwrite=False') with open(filename, 'wb') as f: f.write(self._jup_img.value) - - -def _offset_is_pixel_or_sky(x): - if isinstance(x, u.Quantity): - if x.unit in (u.dimensionless_unscaled, u.pix): - coord = 'data' - val = x.value - else: - coord = 'wcs' - val = x.to_value(u.deg) - else: - coord = 'data' - val = x - - return val, coord diff --git a/astrowidgets/tests/_test_widget_api_ginga.py b/astrowidgets/tests/_test_widget_api_ginga.py deleted file mode 100644 index 338648a..0000000 --- a/astrowidgets/tests/_test_widget_api_ginga.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from astro_image_display_api import ImageWidgetAPITest -from astro_image_display_api import ImageViewerInterface - -ginga = pytest.importorskip("ginga", - reason="Package required for test is not " - "available.") -from astrowidgets.ginga import ImageWidget # noqa: E402 - - -def test_instance(): - image = ImageWidget() - assert isinstance(image, ImageViewerInterface) - - -class TestGingaWidget(ImageWidgetAPITest): - image_widget_class = ImageWidget diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py new file mode 100644 index 0000000..496c299 --- /dev/null +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -0,0 +1,31 @@ +import pytest + +from traitlets import TraitError + +from astro_image_display_api.api_test import ImageAPITest +from astro_image_display_api import ImageViewerInterface + +_ = pytest.importorskip("ginga", + reason="Package required for test is not " + "available.") +from astrowidgets.ginga import ImageWidget # noqa: E402 + + +def test_instance(): + image = ImageWidget() + assert isinstance(image, ImageViewerInterface) + + +class TestGingaWidget(ImageAPITest): + image_widget_class = ImageWidget + cursor_error_classes = (ValueError, TraitError) + + @pytest.mark.skip(reason="Saving requires a running browser to populate " + "the image buffer.") + def test_save(self, tmp_path): + pass + + @pytest.mark.skip(reason="Saving requires a running browser to populate " + "the image buffer.") + def test_save_overwrite(self, tmp_path): + pass From 5e8d642f73408566c1300f8d939da9f1e44d73a9 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sat, 27 Jun 2026 12:55:56 -0500 Subject: [PATCH 02/14] Rewrite ginga example notebook to mirror bqplot Rebuild example_notebooks/ginga_widget.ipynb around the AIDA API, mirroring bqplot_widget.ipynb cell-for-cell for the shared sections (load_image, set/get_viewport with pixel/SkyCoord/angular fov, cuts/stretch, colormap, catalog load/style/remove including a use_skycoord catalog and the ipywidgets slider demo, get_image/save). Because ginga has a real WCS, the viewport and catalog cells run for real rather than as placeholders. The interactive tail is replaced with working ginga-native demos -- the key-bindings table, live cursor bar, click_center, and start_marking/stop_marking/is_marking with the points retrieved via get_catalog -- instead of bqplot's "not available" notes. Imports now use `from astrowidgets.ginga import ImageWidget`. Verified by executing the notebook headless with no cell errors. Co-written with Claude Opus 4.8 --- example_notebooks/ginga_widget.ipynb | 539 +++++++++++++++++++-------- 1 file changed, 379 insertions(+), 160 deletions(-) diff --git a/example_notebooks/ginga_widget.ipynb b/example_notebooks/ginga_widget.ipynb index c422fca..c0ef708 100644 --- a/example_notebooks/ginga_widget.ipynb +++ b/example_notebooks/ginga_widget.ipynb @@ -20,7 +20,10 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets import ImageWidget" + "%load_ext autoreload\n", + "%autoreload 2\n", + "from astropy.visualization import AsymmetricPercentileInterval\n", + "from astrowidgets.ginga import ImageWidget" ] }, { @@ -41,7 +44,7 @@ "metadata": {}, "outputs": [], "source": [ - "w = ImageWidget(logger=logger)" + "w = ImageWidget()" ] }, { @@ -52,14 +55,15 @@ "\n", "Alternately, for local FITS file, you could load it like this instead:\n", "```python\n", - "w.load_fits(filename, numhdu=numhdu)\n", + "w.load_image(filename)\n", "``` \n", "Or if you wish to load a data array natively (without WCS):\n", "```python\n", "from astropy.io import fits\n", + "import numpy as np\n", "with fits.open(filename, memmap=False) as pf:\n", " arr = pf[numhdu].data.copy()\n", - "w.load_array(arr)\n", + "w.load_image(arr)\n", "```" ] }, @@ -77,35 +81,7 @@ "# NOTE: Some file also requires unit to be explicitly set in CCDData.\n", "from astropy.nddata import CCDData\n", "ccd = CCDData.read(filename, hdu=numhdu, format='fits')\n", - "w.load_nddata(ccd)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Ginga key bindings documented at http://ginga.readthedocs.io/en/latest/quickref.html . Note that not all documented bindings would work here. Please use an alternate binding, if available, if the chosen one is not working.\n", - "\n", - "Here are the ones that worked during testing with Firefox 52.8.0 on RHEL7 64-bit:\n", - "\n", - "Key | Action | Notes\n", - "--- | --- | ---\n", - "`+` | Zoom in |\n", - "`-` | Zoom out |\n", - "Number (0-9) | Zoom in to specified level | 0 = 10\n", - "Shift + number | Zoom out to specified level | Numpad does not work\n", - "` (backtick) | Reset zoom |\n", - "Space > `q` > arrow | Pan |\n", - "ESC | Exit mode (pan, etc) |\n", - "`c` | Center image\n", - "Space > `d` > up/down arrow | Cycle through color distributions\n", - "Space > `d` > Shift + `d` | Go back to linear color distribution\n", - "Space > `s` > Shift + `s` | Set cut level to min/max\n", - "Space > `s` > Shift + `a` | Set cut level to 0/255 (for 8bpp RGB images)\n", - "Space > `s` > up/down arrow | Cycle through cuts algorithms\n", - "Space > `l` | Toggle no/soft/normal lock |\n", - "\n", - "*TODO: Check out Contrast Mode next*" + "w.load_image(ccd)" ] }, { @@ -131,7 +107,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This next cell captures print outputs. You can pop it out like the viewer above. It is very convenient for debugging purpose." + "## Manipulating the widget with the API" ] }, { @@ -140,15 +116,23 @@ "metadata": {}, "outputs": [], "source": [ - "# Capture print outputs from the widget\n", - "display(w.print_out)" + "# Use API methods for viewport control\n", + "\n", + "w.set_viewport(center=(0, 0))\n", + "# Note: zoom_level is not part of the API, use field of view instead\n", + "vport = w.get_viewport()\n", + "w.set_viewport(fov=vport['fov'] / 5) # Zoom in 5x by reducing FOV" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "The following cell changes the visibility or position of the cursor info bar.\n" + "# Set a colormap. Ginga colormap names come from\n", + "# `from ginga import cmap; cmap.get_names()` -- e.g. 'gray', 'viridis'.\n", + "w.set_colormap('gray')" ] }, { @@ -157,8 +141,49 @@ "metadata": {}, "outputs": [], "source": [ - "w.cursor = 'top' # 'top', 'bottom', None\n", - "print(w.cursor)" + "# Get current cuts using API\n", + "print(\"Current cuts:\", w.get_cuts())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Set cuts using API -- any astropy interval works\n", + "from astropy.visualization import AsymmetricPercentileInterval\n", + "w.set_cuts(AsymmetricPercentileInterval(1, 99.9))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get image data and access pixel values using API\n", + "img_data = w.get_image()\n", + "if hasattr(img_data, 'data'):\n", + " print(\"Pixel value at (240, 120):\", img_data.data[240, 120])\n", + " print(\"Data shape:\", img_data.data.shape)\n", + "else:\n", + " print(\"Pixel value at (240, 120):\", img_data[240, 120])\n", + " print(\"Data shape:\", img_data.shape)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get cuts information using API\n", + "cuts = w.get_cuts()\n", + "if hasattr(cuts, 'upper_percentile'):\n", + " print(\"Upper percentile:\", cuts.upper_percentile)\n", + "else:\n", + " print(\"Current cuts:\", cuts)" ] }, { @@ -174,8 +199,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Programmatically center to (X, Y) on viewer\n", - "w.center_on((1, 1))" + "# Programmatically center to (X, Y) on viewer using set_viewport\n", + "w.set_viewport(center=(1, 1))" ] }, { @@ -184,8 +209,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Programmatically offset w.r.t. current center\n", - "w.offset_by(10, 10)" + "# Note: offset_by is not part of the astro-image-display-api\n", + "# Instead, get current center and offset from it\n", + "vport = w.get_viewport(sky_or_pixel='pixel')\n", + "current_center = vport['center']\n", + "if isinstance(current_center, tuple):\n", + " # Pixel coordinates\n", + " new_center = (current_center[0] + 100, current_center[1] + 100)\n", + " w.set_viewport(center=new_center)" ] }, { @@ -200,10 +231,10 @@ "# example image.\n", "ra_str = '01h13m23.193s'\n", "dec_str = '+00d12m32.19s'\n", - "frame = 'icrs'\n", + "frame = 'galactic'\n", "\n", - "# Programmatically center to SkyCoord on viewer\n", - "w.center_on(SkyCoord(ra_str, dec_str, frame=frame))" + "# Programmatically center to SkyCoord on viewer using set_viewport\n", + "w.set_viewport(center=SkyCoord(ra_str, dec_str, frame=frame))" ] }, { @@ -213,13 +244,21 @@ "outputs": [], "source": [ "from astropy import units as u\n", + "import numpy as np\n", "\n", "# Change the values if needed.\n", "deg_offset = 0.001 * u.deg\n", "\n", - "# Programmatically offset (in degrees) w.r.t.\n", - "# SkyCoord center\n", - "w.offset_by(deg_offset, deg_offset)" + "# Note: offset_by with SkyCoord is not part of the astro-image-display-api\n", + "# Instead, get current center and offset from it\n", + "vport = w.get_viewport()\n", + "current_center = vport['center']\n", + "if hasattr(current_center, 'ra'):\n", + " # SkyCoord center - offset in RA/Dec\n", + " new_ra = current_center.ra + deg_offset / np.cos(current_center.dec)\n", + " new_dec = current_center.dec + deg_offset\n", + " new_center = SkyCoord(new_ra, new_dec, frame=current_center.frame)\n", + " w.set_viewport(center=new_center)" ] }, { @@ -228,8 +267,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Show zoom level\n", - "print(w.zoom_level)" + "current_center" ] }, { @@ -238,8 +276,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Programmatically zoom image on viewer\n", - "w.zoom(2)" + "# Note: zoom_level is not part of the astro-image-display-api\n", + "# Instead, show current field of view\n", + "vport = w.get_viewport()\n", + "print(\"Current field of view:\", vport['fov'])" ] }, { @@ -248,9 +288,19 @@ "metadata": {}, "outputs": [], "source": [ - "# Capture what viewer is showing and save RGB image.\n", - "# Need https://github.com/ejeschke/ginga/pull/688 to work.\n", - "w.save('test.png')" + "# Note: zoom() is not part of the astro-image-display-api\n", + "# Instead, set a specific field of view to zoom in/out\n", + "# Get current FOV and reduce it by half to zoom in 2x\n", + "vport = w.get_viewport()\n", + "current_fov = vport['fov']\n", + "if hasattr(current_fov, 'value'):\n", + " # Angular FOV with units\n", + " new_fov = current_fov / 2\n", + " w.set_viewport(fov=new_fov)\n", + "else:\n", + " # Pixel FOV \n", + " new_fov = current_fov / 2\n", + " w.set_viewport(fov=new_fov)" ] }, { @@ -259,8 +309,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Get all available image stretch options\n", - "print(w.stretch_options)" + "from astropy import units as u\n", + "w.set_viewport(fov=0.5 *u.degree)" ] }, { @@ -269,8 +319,9 @@ "metadata": {}, "outputs": [], "source": [ - "# Get image stretch algorithm in use\n", - "print(w.stretch)" + "# Save the current view to a PNG. This requires a running browser to\n", + "# have populated the image buffer, so it is display-only here.\n", + "w.save('test_ginga.png', overwrite=True)" ] }, { @@ -279,9 +330,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Change the stretch\n", - "w.stretch = 'histeq'\n", - "print(w.stretch)" + "# Note: stretch_options is not part of the astro-image-display-api\n", + "# The API uses astropy visualization stretch objects\n", + "from astropy.visualization import LinearStretch, LogStretch, SqrtStretch, PowerStretch\n", + "print(\"Available stretch types: LinearStretch, LogStretch, SqrtStretch, PowerStretch, etc.\")" ] }, { @@ -290,8 +342,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Get all available image cuts options\n", - "print(w.autocut_options)" + "# Get image stretch algorithm in use\n", + "print(w.get_stretch())" ] }, { @@ -300,8 +352,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Get image cut levels in use\n", - "print(w.cuts)" + "# Change the stretch using astropy visualization objects\n", + "from astropy.visualization import LogStretch\n", + "w.set_stretch(LogStretch())\n", + "print(w.get_stretch())" ] }, { @@ -310,9 +364,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Change the cuts by providing explicit low/high values\n", - "w.cuts = (0, 100)\n", - "print(w.cuts)" + "# Note: autocut_options is not part of the astro-image-display-api\n", + "# The API uses astropy visualization interval objects\n", + "from astropy.visualization import MinMaxInterval, PercentileInterval, AsymmetricPercentileInterval\n", + "print(\"Available cuts types: MinMaxInterval, PercentileInterval, AsymmetricPercentileInterval, etc.\")" ] }, { @@ -321,9 +376,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Change the cuts with an autocut algorithm\n", - "w.cuts = 'zscale'\n", - "print(w.cuts)" + "# Get image cut levels in use\n", + "print(w.get_cuts())" ] }, { @@ -332,15 +386,9 @@ "metadata": {}, "outputs": [], "source": [ - "# This enables click to center.\n", - "w.click_center = True" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now, click on the image to center it." + "# Change the cuts by providing explicit low/high values\n", + "w.set_cuts((0, 100))\n", + "print(w.get_cuts())" ] }, { @@ -349,26 +397,24 @@ "metadata": {}, "outputs": [], "source": [ - "# Turn it back off so marking (next cell) can be done.\n", - "w.click_center = False" + "# Change the cuts with an astropy visualization interval object\n", + "from astropy.visualization import PercentileInterval\n", + "w.set_cuts(PercentileInterval(95))\n", + "print(w.get_cuts())" ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "# This enables marking mode.\n", - "w.start_marking()\n", - "print(w.is_marking)" + "## Marker/catalog interaction using the API" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Now, click on the image to mark a point of interest." + "### Catalog/marker management using the astro-image-display-api" ] }, { @@ -377,9 +423,19 @@ "metadata": {}, "outputs": [], "source": [ - "# When done, set back to False.\n", - "w.stop_marking()\n", - "print(w.is_marking)" + "# Create a sample catalog to demonstrate catalog loading\n", + "import numpy as np\n", + "from astropy.table import Table\n", + "\n", + "# Create some sample marker positions\n", + "n_markers = 5\n", + "x_coords = np.random.uniform(50, 200, n_markers)\n", + "y_coords = np.random.uniform(50, 150, n_markers)\n", + "sample_catalog = Table([x_coords, y_coords], names=['x', 'y'])\n", + "\n", + "# Load catalog using the API\n", + "w.load_catalog(sample_catalog, catalog_label='sample')\n", + "print(\"Loaded catalog with\", len(sample_catalog), \"entries\")" ] }, { @@ -388,16 +444,13 @@ "metadata": {}, "outputs": [], "source": [ - "# Get table of markers\n", - "markers_table = w.get_markers(marker_name='all')\n", + "# Get catalog data using the API\n", + "retrieved_catalog = w.get_catalog(catalog_label='sample')\n", "\n", - "# Default display might be hard to read, so we do this\n", - "print('{:^8s} {:^8s} {:^28s}'.format(\n", - " 'X', 'Y', 'Coordinates'))\n", - "for row in markers_table:\n", - " c = row['coord'].to_string('hmsdms')\n", - " print('{:8.2f} {:8.2f} {}'.format(\n", - " row['x'], row['y'], c))" + "# Display the catalog nicely\n", + "print('{:^8s} {:^8s}'.format('X', 'Y'))\n", + "for row in retrieved_catalog:\n", + " print('{:8.2f} {:8.2f}'.format(row['x'], row['y']))" ] }, { @@ -406,15 +459,16 @@ "metadata": {}, "outputs": [], "source": [ - "# Erase markers from display\n", - "w.reset_markers()" + "# Remove catalog using the API\n", + "w.remove_catalog(catalog_label='sample')\n", + "print(\"Removed sample catalog\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The following works even when we have set `w.is_marking=False`. This is because `w.is_marking` only controls the interactive marking and does not affect marking programmatically." + "### Programmatic catalog loading and styling using the astro-image-display-api." ] }, { @@ -423,28 +477,16 @@ "metadata": {}, "outputs": [], "source": [ - "# Programmatically re-mark from table using X, Y.\n", - "# To be fancy, first 2 points marked as bigger\n", - "# and thicker red circles.\n", - "w.marker = {'type': 'circle', 'color': 'red', 'radius': 50,\n", - " 'linewidth': 2}\n", - "w.add_markers(markers_table[:2])\n", - "# You can also change the type of marker to cross or plus\n", - "w.marker = {'type': 'cross', 'color': 'cyan', 'radius': 20}\n", - "w.add_markers(markers_table[2:])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Erase them again\n", - "w.reset_markers()\n", + "# Create sample catalogs with different styles\n", + "# First 2 points as red circles, rest as cyan crosses\n", + "red_catalog = Table([x_coords[:2], y_coords[:2]], names=['x', 'y'])\n", + "w.load_catalog(red_catalog, catalog_label='red_circles')\n", + "w.set_catalog_style(catalog_label='red_circles', color='red', shape='circle', size=50, linewidth=2)\n", "\n", - "# Programmatically re-mark from table using SkyCoord\n", - "w.add_markers(markers_table, use_skycoord=True)" + "# Rest as cyan crosses\n", + "cyan_catalog = Table([x_coords[2:], y_coords[2:]], names=['x', 'y'])\n", + "w.load_catalog(cyan_catalog, catalog_label='cyan_crosses')\n", + "w.set_catalog_style(catalog_label='cyan_crosses', color='cyan', shape='cross', size=20)" ] }, { @@ -453,9 +495,26 @@ "metadata": {}, "outputs": [], "source": [ - "# Start marking again\n", - "w.start_marking()\n", - "print(w.is_marking)" + "# Clear existing catalogs and load new one with SkyCoord\n", + "try:\n", + " w.remove_catalog(catalog_label='red_circles')\n", + "except ValueError:\n", + " pass # Catalog doesn't exist\n", + "try:\n", + " w.remove_catalog(catalog_label='cyan_crosses')\n", + "except ValueError:\n", + " pass # Catalog doesn't exist\n", + "\n", + "# Create catalog with SkyCoord coordinates \n", + "from astropy.coordinates import SkyCoord\n", + "from astropy import units as u\n", + "\n", + "# Convert x,y to approximate RA/Dec for demonstration\n", + "ra_vals = (x_coords / 100 + 19.5) * u.deg # Approximate RA\n", + "dec_vals = (y_coords / 100 + 0.2) * u.deg # Approximate Dec\n", + "skycoord_table = Table([SkyCoord(ra_vals, dec_vals, frame=frame)], names=['coord'])\n", + "\n", + "w.load_catalog(skycoord_table, catalog_label='skycoord_markers', use_skycoord=True)" ] }, { @@ -464,17 +523,19 @@ "metadata": {}, "outputs": [], "source": [ - "# Stop marking AND clear markers.\n", - "# Note that this deletes ALL of the markers\n", - "w.stop_marking(clear_markers=True)\n", - "print(w.is_marking)" + "# Clear all catalogs using the API\n", + "try:\n", + " w.remove_catalog(catalog_label='skycoord_markers')\n", + " print(\"Cleared all markers using catalog API\")\n", + "except ValueError:\n", + " print(\"No skycoord_markers catalog to remove\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The next cell randomly generates some \"stars\" to mark. In the real world, you would probably detect real stars using `photutils` package." + "### Using widgets to drive image display" ] }, { @@ -483,32 +544,40 @@ "metadata": {}, "outputs": [], "source": [ + "# Generate random \"stars\" using the image from the API\n", "import numpy as np\n", "from astropy.table import Table\n", "\n", - "# Maximum umber of \"stars\" to generate randomly.\n", + "# Maximum number of \"stars\" to generate randomly\n", "max_stars = 1000\n", "\n", - "# Number of pixels from edge to avoid.\n", + "# Number of pixels from edge to avoid\n", "dpix = 20\n", "\n", - "# Image from the viewer.\n", - "img = w._viewer.get_image()\n", - "\n", - "# Random \"stars\" generated.\n", + "# Get image data using API\n", + "img_data = w.get_image()\n", + "if hasattr(img_data, 'shape'):\n", + " shape = img_data.shape\n", + "elif hasattr(img_data, 'data') and hasattr(img_data.data, 'shape'):\n", + " shape = img_data.data.shape\n", + "else:\n", + " # Fallback\n", + " shape = (256, 256)\n", + " \n", + "# Random \"stars\" generated \n", "bad_locs = np.random.randint(\n", - " dpix, high=img.shape[1] - dpix, size=[max_stars, 2])\n", + " dpix, high=shape[1] - dpix, size=[max_stars, 2])\n", "\n", - "# Only want those not near the edges.\n", + "# Only want those not near the edges\n", "mask = ((dpix < bad_locs[:, 0]) &\n", - " (bad_locs[:, 0] < img.shape[0] - dpix) &\n", + " (bad_locs[:, 0] < shape[0] - dpix) &\n", " (dpix < bad_locs[:, 1]) &\n", - " (bad_locs[:, 1] < img.shape[1] - dpix))\n", + " (bad_locs[:, 1] < shape[1] - dpix))\n", "locs = bad_locs[mask]\n", "\n", "# Put them in table\n", "t = Table([locs[:, 1], locs[:, 0]], names=('x', 'y'))\n", - "print(t)" + "print(f\"Generated {len(t)} random star positions\")\n" ] }, { @@ -517,8 +586,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Mark those \"stars\" based on given table with X and Y.\n", - "w.add_markers(t)" + "# Load the \"stars\" as a catalog\n", + "w.load_catalog(t, catalog_label='stars')" ] }, { @@ -534,17 +603,27 @@ "metadata": {}, "outputs": [], "source": [ - "# Set the marker properties as you like.\n", - "w.marker = {'type': 'circle', 'color': 'red', 'radius': 10,\n", - " 'linewidth': 2}\n", + "import ipywidgets as ipyw\n", "\n", - "# Define a function to control marker display\n", + "# Set catalog style and create function to control display\n", + "w.set_catalog_style(catalog_label='stars', color='red', shape='circle', size=10, linewidth=2)\n", + "output = ipyw.Output()\n", + "# Show the slider widget.\n", + "# Define a function to control marker display using catalog API\n", "def show_circles(n):\n", - " \"\"\"Show and hide circles.\"\"\"\n", - " w.reset_markers()\n", + " \"\"\"Show subset of stars using catalog API.\"\"\"\n", + " # Remove existing catalog\n", + " try:\n", + " w.remove_catalog(catalog_label='stars')\n", + " except ValueError as exc:\n", + " raise exc # Catalog doesn't exist\n", + " \n", + " # Create subset catalog\n", " t2show = t[:n]\n", - " w.add_markers(t2show)\n", - " with w.print_out:\n", + " w.load_catalog(t2show, catalog_label='stars')\n", + " w.set_catalog_style(catalog_label='stars', color='red', shape='circle', size=10, linewidth=2)\n", + " \n", + " with output:\n", " print('Displaying {} markers...'.format(len(t2show)))" ] }, @@ -566,10 +645,11 @@ "import ipywidgets as ipyw\n", "from ipywidgets import interactive\n", "\n", + "\n", "# Show the slider widget.\n", "slider = interactive(show_circles,\n", " n=ipyw.IntSlider(min=0,max=len(t),step=1,value=0, continuous_update=False))\n", - "display(w, slider)" + "display(w, slider, output)" ] }, { @@ -578,6 +658,145 @@ "source": [ "Now, use the slider. The chosen `n` represents the first `n` \"stars\" being displayed." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Interactive features (Ginga-native)\n", + "\n", + "Unlike the bqplot backend, the Ginga viewer supports a number of interactive\n", + "features natively. These are *not* part of the astro-image-display-api, but are\n", + "available as conveniences on the Ginga `ImageWidget`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Ginga key bindings\n", + "\n", + "Ginga key bindings are documented at http://ginga.readthedocs.io/en/latest/quickref.html .\n", + "Note that not all documented bindings work in the notebook. Please use an alternate\n", + "binding, if available, when the chosen one does not work.\n", + "\n", + "Some bindings that have worked during testing:\n", + "\n", + "Key | Action | Notes\n", + "--- | --- | ---\n", + "`+` | Zoom in |\n", + "`-` | Zoom out |\n", + "Number (0-9) | Zoom in to specified level | 0 = 10\n", + "Shift + number | Zoom out to specified level | Numpad does not work\n", + "`` ` `` (backtick) | Reset zoom |\n", + "Space > `q` > arrow | Pan |\n", + "ESC | Exit mode (pan, etc) |\n", + "`c` | Center image |\n", + "Space > `d` > up/down arrow | Cycle through color distributions |\n", + "Space > `d` > Shift + `d` | Go back to linear color distribution |\n", + "Space > `s` > Shift + `s` | Set cut level to min/max |\n", + "Space > `s` > up/down arrow | Cycle through cuts algorithms |\n", + "Space > `l` | Toggle no/soft/normal lock |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Redisplay the widget\n", + "\n", + "Display the widget again here so the interactive cells below have a viewer to act on.\n", + "The `print_out` output widget captures messages printed by interactive callbacks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display(w, w.print_out)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following cell changes the visibility or position of the cursor info bar." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "w.cursor = 'top' # 'top', 'bottom', or None\n", + "print(w.cursor)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Click to center\n", + "\n", + "With `click_center` enabled, clicking on the image re-centers the view on the\n", + "clicked point. Run the cell below, then click somewhere on the image above." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "w.click_center = True\n", + "print('click_center:', w.click_center)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Turn click-to-center back off when you are done.\n", + "w.click_center = False" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Interactive marking\n", + "\n", + "Start marking, then click on the image to drop markers. The points are stored in a\n", + "catalog (here labelled ``my_markers``) and can be retrieved with `get_catalog`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "w.start_marking(marker_name='my_markers',\n", + " marker={'shape': 'circle', 'color': 'cyan', 'size': 20})\n", + "print('is_marking:', w.is_marking)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# When you have placed some markers, stop marking and retrieve them.\n", + "w.stop_marking()\n", + "print('is_marking:', w.is_marking)\n", + "w.get_catalog(catalog_label='my_markers')" + ] } ], "metadata": { @@ -600,5 +819,5 @@ } }, "nbformat": 4, - "nbformat_minor": 2 -} + "nbformat_minor": 4 +} \ No newline at end of file From 13ac4805569b38b37c080db0bc30da5e52d4bae9 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 10:45:08 -0500 Subject: [PATCH 03/14] Tidy up ginga widget initialization Use ImageViewerLogic.__post_init__ to set up dataclass state instead of hand-rolling the call to _set_up_catalog_image_dicts, drop the stale ipyevents comment, and remove the redundant image_label resolution in load_image (the helpers resolve it internally). Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 952289a..32e08ba 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -97,9 +97,9 @@ class ImageWidget(ipyw.VBox, ImageViewerLogic): def __init__(self, *args, logger=None, image_width=500, image_height=500, pixel_coords_offset=0, **kwargs): super().__init__(*args) - # ImageViewerLogic is a dataclass; we do not run its __init__, so set - # up the state it would otherwise provide by hand. - self._set_up_catalog_image_dicts() + # ImageViewerLogic is a dataclass; we do not run its __init__, so run + # the post-init hook it would otherwise provide to set up its state. + ImageViewerLogic.__post_init__(self) self._wcs = None if 'use_opencv' in kwargs: @@ -167,7 +167,6 @@ def __init__(self, *args, logger=None, image_width=500, image_height=500, # 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) @@ -286,9 +285,9 @@ def load_image(self, image, image_label=None, **kwargs): # Let the AIDA logic store the data + WCS and set up the initial # viewport, cuts and stretch in our internal state. super().load_image(image, image_label=image_label, **kwargs) - image_label = self._resolve_image_label(image_label) - # Build a ginga AstroImage from the stored data and show it. + # Build a ginga AstroImage from the stored data and show it. The + # helpers resolve image_label internally, matching the bqplot backend. data = self.get_image(image_label=image_label) self._viewer.set_image(self._build_ginga_image(data)) @@ -387,7 +386,6 @@ def remove_catalog(self, catalog_label=None, **kwargs): tags = [str(label) for label in self._catalogs] else: tags = [str(self._resolve_catalog_label(catalog_label))] - super().remove_catalog(catalog_label, **kwargs) for tag in tags: From 5261b9ae2d241df2e6bc63e8befde80191505030 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 10:45:55 -0500 Subject: [PATCH 04/14] Forward widget kwargs to VBox Pass extra keyword arguments through to ipywidgets.VBox so custom widget traits (e.g. layout) are honored. The deprecated use_opencv kwarg is popped first so it still warns rather than reaching VBox as an unknown trait. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 15 +++++++++------ astrowidgets/tests/test_widget_api_ginga.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 32e08ba..2a44165 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -96,17 +96,20 @@ class ImageWidget(ipyw.VBox, ImageViewerLogic): def __init__(self, *args, logger=None, image_width=500, image_height=500, pixel_coords_offset=0, **kwargs): - super().__init__(*args) - # ImageViewerLogic is a dataclass; we do not run its __init__, so run - # the post-init hook it would otherwise provide to set up its state. - ImageViewerLogic.__post_init__(self) - self._wcs = None - if 'use_opencv' in kwargs: + # Pop it so it is not forwarded to VBox, which would raise a + # TraitError for an unknown trait. + kwargs.pop('use_opencv') warnings.warn("use_opencv kwarg has been deprecated--" "opencv will be used if it is installed", DeprecationWarning) + super().__init__(*args, **kwargs) + # ImageViewerLogic is a dataclass; we do not run its __init__, so run + # the post-init hook it would otherwise provide to set up its state. + ImageViewerLogic.__post_init__(self) + self._wcs = None + self._viewer = EnhancedCanvasView(logger=logger) self._pixel_offset = pixel_coords_offset diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index 496c299..05d42ec 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -1,5 +1,6 @@ import pytest +import ipywidgets as ipyw from traitlets import TraitError from astro_image_display_api.api_test import ImageAPITest @@ -16,6 +17,20 @@ def test_instance(): assert isinstance(image, ImageViewerInterface) +def test_widget_kwargs_forwarded_to_vbox(): + # Widget keyword arguments should be passed through to the underlying + # ipywidgets.VBox, so e.g. a custom layout is honored. + image = ImageWidget(layout=ipyw.Layout(border='1px solid red')) + assert image.layout.border == '1px solid red' + + +def test_use_opencv_kwarg_warns_not_errors(): + # The deprecated use_opencv kwarg should warn rather than be forwarded to + # VBox (which would raise a TraitError for an unknown trait). + with pytest.warns(DeprecationWarning): + ImageWidget(use_opencv=True) + + class TestGingaWidget(ImageAPITest): image_widget_class = ImageWidget cursor_error_classes = (ValueError, TraitError) From db79258aad15db52b26358c914de7f47f472c917 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 10:48:11 -0500 Subject: [PATCH 05/14] Remove pixel_offset Drop the pixel_coords_offset constructor argument, the pixel_offset property, and the associated offset arithmetic in the mouse callbacks and _center_on. The API-approved way to shift coordinates is through the viewport, so this special-case offset is no longer needed. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 30 +++------------------ astrowidgets/tests/test_widget_api_ginga.py | 11 ++++++++ 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 2a44165..abcaa0b 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -75,12 +75,6 @@ class ImageWidget(ipyw.VBox, ImageViewerLogic): image_width, image_height : int Dimension of the Jupyter notebook's image widget, in pixels. - - 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.* """ # Allowed locations for cursor display ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] @@ -95,7 +89,7 @@ class ImageWidget(ipyw.VBox, ImageViewerLogic): DEFAULT_INTERACTIVE_MARKER_NAME: str = 'interactive' def __init__(self, *args, logger=None, image_width=500, image_height=500, - pixel_coords_offset=0, **kwargs): + **kwargs): if 'use_opencv' in kwargs: # Pop it so it is not forwarded to VBox, which would raise a # TraitError for an unknown trait. @@ -112,8 +106,6 @@ def __init__(self, *args, logger=None, image_width=500, image_height=500, self._viewer = EnhancedCanvasView(logger=logger) - self._pixel_offset = pixel_coords_offset - self._jup_img = ipyw.Image(format='jpeg') # Set the image margin over the widget's default of 2px on all sides. @@ -214,18 +206,6 @@ def image_height(self, value): 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 - @property def print_out(self): """ @@ -254,8 +234,7 @@ def _mouse_move_cb(self, viewer, button, data_x, data_y): except Exception: imval = 'N/A' - val = 'X: {:.2f}, Y: {:.2f}'.format(data_x + self._pixel_offset, - data_y + self._pixel_offset) + val = 'X: {:.2f}, Y: {:.2f}'.format(data_x, data_y) if image.wcs.wcs is not None: try: ra, dec = image.pixtoradec(data_x, data_y) @@ -278,8 +257,7 @@ def _mouse_click_cb(self, viewer, event, data_x, data_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)) + print('Centered on X={} Y={}'.format(data_x, data_y)) # ------------------------------------------------------------------ # Image loading @@ -552,7 +530,7 @@ def _center_on(self, point): self._viewer.set_pan(point.icrs.ra.deg, point.icrs.dec.deg, coord='wcs') else: - self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset)) + self._viewer.set_pan(*np.asarray(point)) @property def cursor(self): diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index 05d42ec..f40c555 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -31,6 +31,17 @@ def test_use_opencv_kwarg_warns_not_errors(): ImageWidget(use_opencv=True) +def test_pixel_offset_removed(): + # The pixel_coords_offset kwarg and pixel_offset property have been + # removed; the API-approved way to handle this is via the viewport. + image = ImageWidget() + assert not hasattr(image, 'pixel_offset') + # pixel_coords_offset is no longer a recognized parameter, so it falls + # through to traitlets as an unrecognized argument. + with pytest.warns(DeprecationWarning, match='pixel_coords_offset'): + ImageWidget(pixel_coords_offset=1) + + class TestGingaWidget(ImageAPITest): image_widget_class = ImageWidget cursor_error_classes = (ValueError, TraitError) From 36e794003357be314b7e40f46b427aac33ebe559 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 10:49:33 -0500 Subject: [PATCH 06/14] Route _center_on through set_viewport Replace the direct ginga set_pan calls in _center_on with a call to the public set_viewport, so all re-centering goes through the AIDA viewport bookkeeping (including the WCS handling for SkyCoord arguments). Behavior is unchanged; the new tests are regression coverage. The now-unused SkyCoord import is dropped. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 7 +--- astrowidgets/tests/test_widget_api_ginga.py | 36 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index abcaa0b..8567210 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -7,7 +7,6 @@ # THIRD-PARTY import numpy as np -from astropy.coordinates import SkyCoord from astropy.nddata import NDData from astropy.table import Table @@ -526,11 +525,7 @@ def _center_on(self, point): Center the view on a point given either as a `~astropy.coordinates.SkyCoord` or as a tuple of pixel ``(X, Y)`` coordinates. """ - if isinstance(point, SkyCoord): - self._viewer.set_pan(point.icrs.ra.deg, point.icrs.dec.deg, - coord='wcs') - else: - self._viewer.set_pan(*np.asarray(point)) + self.set_viewport(center=point) @property def cursor(self): diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index f40c555..aab5284 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -1,11 +1,25 @@ +import numpy as np import pytest import ipywidgets as ipyw +from astropy.coordinates import SkyCoord +from astropy.nddata import NDData +from astropy.wcs import WCS from traitlets import TraitError from astro_image_display_api.api_test import ImageAPITest from astro_image_display_api import ImageViewerInterface + +def _make_wcs(): + w = WCS(naxis=2) + w.wcs.crpix = [-234.75, 8.3393] + w.wcs.cdelt = np.array([-0.066667, 0.066667]) + w.wcs.crval = [0, -90] + w.wcs.ctype = ["RA---AIR", "DEC--AIR"] + w.wcs.set_pv([(2, 1, 45.0)]) + return w + _ = pytest.importorskip("ginga", reason="Package required for test is not " "available.") @@ -42,6 +56,28 @@ def test_pixel_offset_removed(): ImageWidget(pixel_coords_offset=1) +def test_center_on_pixel_updates_viewport(): + # _center_on with a pixel tuple should re-center the stored viewport. + image = ImageWidget() + image.load_image(np.zeros((100, 150))) + image._center_on((30, 40)) + center = image.get_viewport(sky_or_pixel='pixel')['center'] + assert center[0] == pytest.approx(30) + assert center[1] == pytest.approx(40) + + +def test_center_on_skycoord_updates_viewport(): + # _center_on with a SkyCoord should re-center the stored viewport. + wcs = _make_wcs() + image = ImageWidget() + image.load_image(NDData(data=np.zeros((100, 150)), wcs=wcs)) + target = SkyCoord(*wcs.wcs.crval, unit='deg') + image._center_on(target) + center = image.get_viewport(sky_or_pixel='sky')['center'] + assert isinstance(center, SkyCoord) + assert center.separation(target).arcsec == pytest.approx(0, abs=1e-3) + + class TestGingaWidget(ImageAPITest): image_widget_class = ImageWidget cursor_error_classes = (ValueError, TraitError) From 8c1e8e3ab992136155399b4bf2ead28a1f99ebf6 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 10:50:24 -0500 Subject: [PATCH 07/14] Make image size attributes read-only Drop the image_width/image_height setters; the API-approved way to resize the view is through the viewport. The getters are kept because they are used by _viewport_window_size and set_window_size. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 12 ------------ astrowidgets/tests/test_widget_api_ginga.py | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 8567210..091d1a9 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -188,23 +188,11 @@ def image_width(self): """Width of the image widget, in pixels.""" 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 here. - self._jup_img.width = str(value) - self._viewer.set_window_size(self.image_width, self.image_height) - @property def image_height(self): """Height of the image widget, in pixels.""" return int(self._jup_img.height) - @image_height.setter - def image_height(self, value): - self._jup_img.height = str(value) - self._viewer.set_window_size(self.image_width, self.image_height) - @property def print_out(self): """ diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index aab5284..10f2bad 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -56,6 +56,20 @@ def test_pixel_offset_removed(): ImageWidget(pixel_coords_offset=1) +def test_image_size_getters_are_read_only(): + # image_width/image_height are read-only; resizing goes through the + # viewport API instead. + image = ImageWidget(image_width=400, image_height=300) + assert isinstance(image.image_width, int) + assert isinstance(image.image_height, int) + assert image.image_width == 400 + assert image.image_height == 300 + with pytest.raises(AttributeError): + image.image_width = 600 + with pytest.raises(AttributeError): + image.image_height = 600 + + def test_center_on_pixel_updates_viewport(): # _center_on with a pixel tuple should re-center the stored viewport. image = ImageWidget() From 60287b794d45954c023f35c1bd75c9048fc47c37 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 10:58:55 -0500 Subject: [PATCH 08/14] Render save() server-side via ginga Use the ginga viewer's save_rgb_image_as_file renderer instead of writing out the JPEG-encoded widget buffer. This produces a file in the format implied by the extension (e.g. a real PNG for a .png filename) and does not depend on a running browser. The overwrite guard is unchanged. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 7 +++---- astrowidgets/tests/test_widget_api_ginga.py | 10 ---------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 091d1a9..90648c8 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -702,10 +702,9 @@ def save(self, filename, overwrite=False, **kwargs): overwrite : bool, optional If `True`, overwrite an existing file. """ - # The widget value is already PNG-encoded, so just write it out. This - # requires a running browser to have populated the buffer. if not overwrite and Path(filename).exists(): raise FileExistsError(f'File {filename} exists and overwrite=False') - with open(filename, 'wb') as f: - f.write(self._jup_img.value) + # Ginga renders the view server-side, so this works without a running + # browser and honors the image format implied by the file extension. + self._viewer.save_rgb_image_as_file(str(filename)) diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index 10f2bad..6040246 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -95,13 +95,3 @@ def test_center_on_skycoord_updates_viewport(): class TestGingaWidget(ImageAPITest): image_widget_class = ImageWidget cursor_error_classes = (ValueError, TraitError) - - @pytest.mark.skip(reason="Saving requires a running browser to populate " - "the image buffer.") - def test_save(self, tmp_path): - pass - - @pytest.mark.skip(reason="Saving requires a running browser to populate " - "the image buffer.") - def test_save_overwrite(self, tmp_path): - pass From fb385f59a283469957e14a251f3f1235268ea1dd Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 11:01:54 -0500 Subject: [PATCH 09/14] Document cuts and colormap handling Replace the open review questions about cuts limits and the colormap None guard with the confirmed answers: get_limits is exact for any interval type, and the colormap legitimately defaults to None. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 90648c8..c541444 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -304,6 +304,9 @@ def _apply_cuts_to_ginga(self, image_label): if ginga_image is None: return cuts = self.get_cuts(image_label=image_label) + # get_cuts always returns a BaseInterval, and get_limits computes the + # concrete low/high for any interval type (percentile, zscale, etc.), + # so this is exact even for non-min/max cuts. low, high = cuts.get_limits(ginga_image.get_data()) self._viewer.cut_levels(low, high) @@ -328,6 +331,8 @@ def _apply_colormap_to_ginga(self, image_label): if self._viewer.get_image() is None: return cmap_name = self.get_colormap(image_label=image_label) + # The colormap defaults to None and is not set on load, so guard + # against pushing a None into ginga's set_color_map. if cmap_name is not None: self._viewer.set_color_map(cmap_name) From 17c9280eedcf1bfedb61242b0d5a67be617e1407 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 11:03:00 -0500 Subject: [PATCH 10/14] Document non-square viewport handling The viewport conversion already works for a non-square viewer: ginga scales isotropically and both set_viewport and get_viewport use the window width as the single fov reference dimension, so the round-trip is exact regardless of aspect ratio. Document this in _viewport_window_size and add a regression test for a non-square viewer. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 10 ++++++++-- astrowidgets/tests/test_widget_api_ginga.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index c541444..5bf8c0a 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -420,8 +420,14 @@ def _draw_catalog(self, catalog_label): def _viewport_window_size(self): """ The window dimension (in screen pixels) used to convert between a - pixel field of view and a ginga scale. The viewer is square in the - default configuration, so either dimension works; use the width. + pixel field of view and a ginga scale. + + Ginga scales the image isotropically (the same screen-pixels-per-data- + pixel in both directions), so we define the field of view as the + *horizontal* extent and always use the window width as the reference + dimension. This is self-consistent for a non-square viewer: both + set_viewport and get_viewport go through this single dimension, so the + round-trip is exact regardless of the window aspect ratio. """ return self._viewer.get_window_size()[0] diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index 6040246..84567d1 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -70,6 +70,19 @@ def test_image_size_getters_are_read_only(): image.image_height = 600 +@pytest.mark.parametrize('fov', [50, 120, 200]) +def test_non_square_viewport_fov_round_trips(fov): + # The widget need not be square: fov is defined as the horizontal field of + # view, so it round-trips through set/get_viewport for a non-square viewer. + image = ImageWidget(image_width=600, image_height=300) + image.load_image(np.random.default_rng(1234).random((100, 150))) + image.set_viewport(center=(40, 60), fov=fov) + vport = image.get_viewport(sky_or_pixel='pixel') + assert vport['fov'] == pytest.approx(fov) + assert vport['center'][0] == pytest.approx(40) + assert vport['center'][1] == pytest.approx(60) + + def test_center_on_pixel_updates_viewport(): # _center_on with a pixel tuple should re-center the stored viewport. image = ImageWidget() From 9227f0c1a1740f21d96ba5c60cfcbafd6eaae8aa Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 28 Jun 2026 14:13:49 -0500 Subject: [PATCH 11/14] Harden WCS handling in _build_ginga_image Widen the try/except to cover the whole WCS-construction block (header serialization, ginga's header parser, and set_wcs), any of which can fail on a malformed or exotic WCS, so a bad WCS degrades gracefully to displaying the image without one. Keep catching Exception (not BaseException, so Ctrl-C still propagates) but log the traceback via the ginga logger instead of printing only the message. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 5bf8c0a..bc152d1 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -277,14 +277,19 @@ def _build_ginga_image(self, data): image.set_data(np.asarray(data.data)) wcs = getattr(data, 'wcs', None) if wcs is not None: - from ginga.util.wcsmod.wcs_astropy import AstropyWCS - _wcs = AstropyWCS(self.logger) - _wcs.load_header(wcs.to_header()) + # A bad or exotic WCS can fail anywhere in here -- header + # serialization, ginga's header parser, or set_wcs -- so guard + # the whole block and degrade gracefully to displaying the image + # without a WCS. Catch Exception (not BaseException) so Ctrl-C + # still propagates, and log the traceback for debugging. try: + from ginga.util.wcsmod.wcs_astropy import AstropyWCS + _wcs = AstropyWCS(self.logger) + _wcs.load_header(wcs.to_header()) image.set_wcs(_wcs) - except Exception as e: # pragma: no cover - defensive - with self._print_out: - print('Unable to set WCS from image: {}'.format(e)) + except Exception: # pragma: no cover - defensive + self.logger.warning('Unable to set WCS from image', + exc_info=True) else: image.set_data(np.asarray(data)) @@ -359,6 +364,7 @@ def remove_catalog(self, catalog_label=None, **kwargs): tags = [str(label) for label in self._catalogs] else: tags = [str(self._resolve_catalog_label(catalog_label))] + super().remove_catalog(catalog_label, **kwargs) for tag in tags: From a1bc3d77321833d5a8b42292a92846cf8031669f Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 29 Jun 2026 09:54:20 -0500 Subject: [PATCH 12/14] Reproduce astropy stretch parameters in the ginga viewer Ginga's ColorDist classes are parametrized, so a parametrized astropy stretch can be reproduced instead of losing its shape parameter. Inject a fully parametrized distribution via rgbmap.set_dist (set_color_algorithm only ever builds ginga defaults): - AsinhStretch/SinhStretch map exactly onto AsinhDist/SinhDist via factor=1/a, nonlinearity=arcsinh(1/a) (resp. sinh). - LogStretch/PowerDistStretch configure LogDist/PowerDist with exp=a, matching the curve shape to within a quantization level. - SqrtStretch/SquaredStretch/default LinearStretch use the matching parameterless native dist. - Everything ginga lacks a family for -- PowerStretch (x**a, a different family than ginga's 'power' = PowerDistStretch), ContrastBiasStretch, HistEqStretch, composites -- is reproduced exactly by a new _AstropyStretchDist adapter that evaluates the stretch on ginga's 0..1 ramp. This removes the warn-and-fall-back-to-linear path. Tests assert the configured ginga ColorDist's lookup table matches the astropy stretch on the same ramp, that the shape parameter reaches ginga, and that PowerStretch is not mis-mapped to ginga's 'power'. Co-written with Claude Opus 4.8 --- astrowidgets/ginga.py | 100 ++++++++++++++--- astrowidgets/tests/test_widget_api_ginga.py | 117 ++++++++++++++++++++ 2 files changed, 204 insertions(+), 13 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index bc152d1..9d1f240 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -9,11 +9,15 @@ import numpy as np from astropy.nddata import NDData from astropy.table import Table +from astropy.visualization import (AsinhStretch, LinearStretch, LogStretch, + PowerDistStretch, SinhStretch, SqrtStretch, + SquaredStretch) # Jupyter widgets import ipywidgets as ipyw # Ginga +from ginga import ColorDist from ginga.AstroImage import AstroImage from ginga.canvas.CanvasObject import drawCatalog from ginga.web.jupyterw.ImageViewJpw import EnhancedCanvasView @@ -24,17 +28,80 @@ __all__ = ['ImageWidget'] -# Map the astropy.visualization stretch classes to the names ginga uses for -# its color-distribution ("stretch") algorithms. -_ASTROPY_STRETCH_TO_GINGA = { - 'LinearStretch': 'linear', - 'LogStretch': 'log', - 'SqrtStretch': 'sqrt', - 'PowerStretch': 'power', - 'AsinhStretch': 'asinh', - 'SinhStretch': 'sinh', - 'SquaredStretch': 'squared', -} +class _AstropyStretchDist(ColorDist.ColorDistBase): + """ + A ginga color distribution backed directly by an astropy stretch. + + Ginga has no native color-distribution class for some astropy stretches + (e.g. ``PowerStretch`` (x**a), ``ContrastBiasStretch``, ``HistEqStretch``). + Because every astropy stretch maps the unit interval onto itself, the + stretch evaluated on ginga's 0..1 ramp *is* the lookup table ginga needs, + so this adapter reproduces any astropy stretch exactly. + """ + + def __init__(self, hashsize, stretch, colorlen=None): + self.stretch = stretch + super().__init__(hashsize, colorlen=colorlen) + + def calc_hash(self): + base = np.arange(0.0, float(self.hashsize), 1.0) / self.hashsize + out = np.asarray(self.stretch(base, clip=True), dtype=float) + out = np.clip(out, 0.0, 1.0) + # normalize to color range + ll = out * (self.colorlen - 1) + self.hash = ll.astype(np.uint, copy=False) + self.check_hash() + + def get_dist_pct(self, pct): + # Inverse mapping, used to place color-bar ticks. Astropy stretches + # expose ``.inverse``; fall back to the identity if one is unavailable. + pct = np.asarray(pct, dtype=float) + try: + val = np.asarray(self.stretch.inverse(pct), dtype=float) + except (NotImplementedError, AttributeError): + val = pct + return np.clip(val, 0.0, 1.0) + + def __str__(self): + return type(self.stretch).__name__ + + +def _ginga_dist_for_stretch(stretch, hashsize): + """ + Build a ginga `~ginga.ColorDist.ColorDistBase` reproducing an astropy + stretch, honoring its shape parameter. + + Ginga's parametrized distributions are reparametrizations of astropy's + stretches, so where ginga has the family we configure its native class: + the hyperbolic families match exactly, while log/power match the curve + shape to within a quantization level (ginga normalizes by ``log(a)``/``a`` + where astropy uses ``log(a + 1)``/``a - 1``). Stretches ginga lacks -- + including ``PowerStretch`` (x**a), which is a different family than ginga's + ``'power'`` (astropy's ``PowerDistStretch``) -- are handled exactly by + `_AstropyStretchDist`. + """ + if isinstance(stretch, AsinhStretch): + factor = 1.0 / stretch.a + return ColorDist.AsinhDist(hashsize, factor=factor, + nonlinearity=np.arcsinh(factor)) + if isinstance(stretch, SinhStretch): + factor = 1.0 / stretch.a + return ColorDist.SinhDist(hashsize, factor=factor, + nonlinearity=np.sinh(factor)) + if isinstance(stretch, LogStretch): + return ColorDist.LogDist(hashsize, exp=stretch.a) + if isinstance(stretch, PowerDistStretch): + return ColorDist.PowerDist(hashsize, exp=stretch.a) + # SquaredStretch subclasses PowerStretch, so check it before any plain + # PowerStretch would fall through to the adapter. + if isinstance(stretch, SquaredStretch): + return ColorDist.SquaredDist(hashsize) + if isinstance(stretch, SqrtStretch): + return ColorDist.SqrtDist(hashsize) + if (isinstance(stretch, LinearStretch) + and stretch.slope == 1 and stretch.intercept == 0): + return ColorDist.LinearDist(hashsize) + return _AstropyStretchDist(hashsize, stretch) def docs_from_super_if_missing(cls): @@ -316,6 +383,10 @@ def _apply_cuts_to_ginga(self, image_label): self._viewer.cut_levels(low, high) def set_stretch(self, value, image_label=None, **kwargs): + # The astropy stretch (including its shape parameter) is reproduced in + # ginga by configuring the matching native color distribution, or, for + # stretches ginga lacks a family for, by an astropy-backed adapter. + # See _ginga_dist_for_stretch. super().set_stretch(value, image_label=image_label, **kwargs) # Changing the stretch only affects the color mapping, so leave the # current viewport (zoom/pan) untouched. @@ -325,8 +396,11 @@ def _apply_stretch_to_ginga(self, image_label): if self._viewer.get_image() is None: return stretch = self.get_stretch(image_label=image_label) - algorithm = _ASTROPY_STRETCH_TO_GINGA.get(type(stretch).__name__, 'linear') - self._viewer.set_color_algorithm(algorithm) + rgbmap = self._viewer.get_rgbmap() + # Inject a fully parametrized distribution; set_dist fires the rgbmap's + # 'changed' callback, which redraws. (set_color_algorithm cannot carry + # parameters -- it only ever builds a distribution with ginga defaults.) + rgbmap.set_dist(_ginga_dist_for_stretch(stretch, rgbmap.get_hash_size())) def set_colormap(self, map_name, image_label=None, **kwargs): super().set_colormap(map_name, image_label=image_label, **kwargs) diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index 84567d1..bd9c5bd 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -1,9 +1,16 @@ +import warnings + import numpy as np import pytest import ipywidgets as ipyw from astropy.coordinates import SkyCoord from astropy.nddata import NDData +from astropy.utils.exceptions import AstropyUserWarning +from astropy.visualization import (AsinhStretch, ContrastBiasStretch, + LinearStretch, LogStretch, PowerDistStretch, + PowerStretch, SinhStretch, SqrtStretch, + SquaredStretch) from astropy.wcs import WCS from traitlets import TraitError @@ -23,9 +30,30 @@ def _make_wcs(): _ = pytest.importorskip("ginga", reason="Package required for test is not " "available.") +from ginga import ColorDist # noqa: E402 from astrowidgets.ginga import ImageWidget # noqa: E402 +def _loaded_widget(): + image = ImageWidget() + image.load_image(np.random.default_rng(1234).random((100, 150))) + return image + + +def _astropy_levels(stretch, dist): + # Reproduce the integer lookup table ginga would build from an astropy + # stretch evaluated on the same 0..1 ramp, so it can be compared against a + # configured ginga ColorDist's ``hash``. + base = np.arange(0.0, float(dist.hashsize), 1.0) / dist.hashsize + out = np.clip(np.asarray(stretch(base, clip=True), dtype=float), 0.0, 1.0) + return (out * (dist.colorlen - 1)).astype(np.int64) + + +def _max_level_diff(dist, stretch): + return int(np.abs(dist.hash.astype(np.int64) + - _astropy_levels(stretch, dist)).max()) + + def test_instance(): image = ImageWidget() assert isinstance(image, ImageViewerInterface) @@ -70,6 +98,95 @@ def test_image_size_getters_are_read_only(): image.image_height = 600 +@pytest.mark.parametrize('stretch', [AsinhStretch(0.05), SinhStretch(0.2), + SqrtStretch(), SquaredStretch(), + LinearStretch()]) +def test_native_dist_matches_astropy_exactly(stretch): + # For the families ginga implements directly, the configured ginga + # ColorDist is an exact reparametrization of the astropy stretch, so its + # lookup table is identical to evaluating the astropy stretch on the ramp. + image = _loaded_widget() + image.set_stretch(stretch) + dist = image._viewer.get_rgbmap().get_dist() + np.testing.assert_array_equal(dist.hash, _astropy_levels(stretch, dist)) + + +@pytest.mark.parametrize('stretch,max_diff', + [(LogStretch(500), 1), (PowerDistStretch(200), 2)]) +def test_native_dist_matches_astropy_closely(stretch, max_diff): + # Ginga normalizes log/power slightly differently than astropy (log(a) vs + # log(a+1)); the curve shape still matches to within a quantization level. + image = _loaded_widget() + image.set_stretch(stretch) + dist = image._viewer.get_rgbmap().get_dist() + assert _max_level_diff(dist, stretch) <= max_diff + + +def test_asinh_parameter_flows_to_ginga(): + # The astropy shape parameter must reach ginga, not be discarded: + # AsinhStretch(a) -> AsinhDist(factor=1/a, nonlinearity=arcsinh(1/a)). + image = _loaded_widget() + image.set_stretch(AsinhStretch(0.05)) + dist = image._viewer.get_rgbmap().get_dist() + assert isinstance(dist, ColorDist.AsinhDist) + assert dist.factor == pytest.approx(20.0) + assert dist.nonlinearity == pytest.approx(np.arcsinh(20.0)) + + +def test_log_parameter_flows_to_ginga(): + image = _loaded_widget() + image.set_stretch(LogStretch(500)) + dist = image._viewer.get_rgbmap().get_dist() + assert isinstance(dist, ColorDist.LogDist) + assert dist.exp == 500 + + +def test_power_stretch_uses_adapter_not_ginga_power(): + # ginga's 'power' algorithm is (a**x - 1)/(a - 1) -- that is astropy's + # PowerDistStretch, NOT PowerStretch (x**a). PowerStretch has no ginga + # family and must be applied faithfully through the adapter. + image = _loaded_widget() + stretch = PowerStretch(3.0) + image.set_stretch(stretch) + dist = image._viewer.get_rgbmap().get_dist() + assert not isinstance(dist, ColorDist.PowerDist) + np.testing.assert_array_equal(dist.hash, _astropy_levels(stretch, dist)) + + +def test_powerdist_stretch_maps_to_ginga_power(): + image = _loaded_widget() + image.set_stretch(PowerDistStretch(200)) + dist = image._viewer.get_rgbmap().get_dist() + assert isinstance(dist, ColorDist.PowerDist) + assert dist.exp == 200 + + +def test_ginga_less_stretch_applied_faithfully_without_warning(): + # ContrastBiasStretch has no ginga family; the adapter applies it exactly + # and, unlike the old behavior, does not warn or fall back to linear. + image = _loaded_widget() + stretch = ContrastBiasStretch(1.0, 0.5) + with warnings.catch_warnings(): + warnings.simplefilter('error', AstropyUserWarning) + image.set_stretch(stretch) + dist = image._viewer.get_rgbmap().get_dist() + np.testing.assert_array_equal(dist.hash, _astropy_levels(stretch, dist)) + + +@pytest.mark.parametrize('stretch,algorithm', + [(LinearStretch(), 'linear'), (LogStretch(), 'log'), + (AsinhStretch(), 'asinh'), (SinhStretch(), 'sinh'), + (SqrtStretch(), 'sqrt'), (SquaredStretch(), 'squared')]) +def test_native_stretch_reports_family_name(stretch, algorithm): + # Native families keep ginga's algorithm-name introspection working and + # apply without warning. + image = _loaded_widget() + with warnings.catch_warnings(): + warnings.simplefilter('error', AstropyUserWarning) + image.set_stretch(stretch) + assert image._viewer.get_rgbmap().get_hash_algorithm() == algorithm + + @pytest.mark.parametrize('fov', [50, 120, 200]) def test_non_square_viewport_fov_round_trips(fov): # The widget need not be square: fov is defined as the horizontal field of From 0f1678d6928dde532797874916f7431bff778c42 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sat, 4 Jul 2026 10:05:48 -0500 Subject: [PATCH 13/14] Linting fix --- astrowidgets/tests/test_widget_api_ginga.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index bd9c5bd..4640edd 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -27,6 +27,7 @@ def _make_wcs(): w.wcs.set_pv([(2, 1, 45.0)]) return w + _ = pytest.importorskip("ginga", reason="Package required for test is not " "available.") From 5672a7b9663270dab97e7a8d0531dfeb611228d8 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sat, 4 Jul 2026 10:08:25 -0500 Subject: [PATCH 14/14] Minor additional updates to ginga notebook --- example_notebooks/ginga_widget.ipynb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/example_notebooks/ginga_widget.ipynb b/example_notebooks/ginga_widget.ipynb index c0ef708..547fb91 100644 --- a/example_notebooks/ginga_widget.ipynb +++ b/example_notebooks/ginga_widget.ipynb @@ -22,7 +22,7 @@ "source": [ "%load_ext autoreload\n", "%autoreload 2\n", - "from astropy.visualization import AsymmetricPercentileInterval\n", + "from astropy.visualization import AsymmetricPercentileInterval, ZScaleInterval\n", "from astrowidgets.ginga import ImageWidget" ] }, @@ -132,7 +132,7 @@ "source": [ "# Set a colormap. Ginga colormap names come from\n", "# `from ginga import cmap; cmap.get_names()` -- e.g. 'gray', 'viridis'.\n", - "w.set_colormap('gray')" + "w.set_colormap('Greys_r')" ] }, { @@ -801,7 +801,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -815,9 +815,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.13.3" } }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +}