import contextlib
import numbers
import os
from copy import copy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.nddata import CCDData, NDData
from astropy.table import Table
from astropy.units import Quantity
from astropy.visualization import (
AsymmetricPercentileInterval,
BaseInterval,
BaseStretch,
LinearStretch,
ManualInterval,
)
from astropy.wcs import WCS
from astropy.wcs.utils import proj_plane_pixel_scales
from numpy.typing import ArrayLike
from .interface_definition import ImageViewerInterface
__all__ = ["ImageViewerLogic", "docs_from_image_viewer_logic_if_missing"]
#: Label used for an image or catalog that is loaded without an explicit
#: label. There is only ever one unlabeled image and one unlabeled catalog,
#: so loading without a label repeatedly replaces the previous unlabeled
#: image or catalog rather than accumulating new entries. The value is
#: deliberately unlikely to collide with a label a user would choose
#: themselves.
DEFAULT_LABEL = "_internal_default_label"
@dataclass
class CatalogInfo:
"""
Class to hold information about a catalog.
"""
style: dict[str, Any] = field(default_factory=dict)
data: Table | None = None
@dataclass
class ViewportInfo:
"""
Class to hold image and viewport information.
"""
center: SkyCoord | tuple[numbers.Real, numbers.Real] | None = None
fov: float | Quantity | None = None
wcs: WCS | None = None
largest_dimension: int | None = None
stretch: BaseStretch | None = None
cuts: BaseInterval | tuple[numbers.Real, numbers.Real] | None = None
colormap: str | None = None
data: ArrayLike | NDData | CCDData | None = None
def _copy_missing_docs(cls, source):
"""
Copy docstrings from ``source`` to the same-named public methods of
``cls`` that lack a docstring of their own. Methods that already have
a docstring keep it.
"""
for name, method in cls.__dict__.items():
if not name.startswith("_") and not method.__doc__:
source_method = getattr(source, name, None)
if source_method is not None:
method.__doc__ = source_method.__doc__
return cls
def docs_from_interface(cls):
"""
Decorator to copy the docstrings from the interface methods to the
methods in the class. Methods that already have a docstring of their
own keep it.
"""
return _copy_missing_docs(cls, ImageViewerInterface)
[docs]
@dataclass
@docs_from_interface
class ImageViewerLogic:
"""
This viewer does not do anything except making changes to its internal
state to simulate the behavior of a real viewer.
"""
# some internal variable for keeping track of viewer state
_center: tuple[numbers.Real, numbers.Real] = (0.0, 0.0)
def __post_init__(self):
self._set_up_catalog_image_dicts()
# Labels of the images the viewer is currently displaying. Today a
# viewer displays a single image at a time, so the tuple has at most
# one element and ``load_image`` uses replace semantics, but a tuple
# leaves room for multi-image display in the future. The ``_apply_*``
# hooks are only invoked for labels that are members of this tuple.
self._displayed_image_labels: tuple[str, ...] = ()
def _set_up_catalog_image_dicts(self):
# Keys are the user-visible labels of the loaded catalogs and images.
# Entries are created only by the load_* methods, so reading state
# can never create an entry.
self._catalogs: dict[str, CatalogInfo] = {}
self._images: dict[str, ViewportInfo] = {}
def _resolve_label(
self,
label: str | None,
kind: str,
allow_new: bool = False,
) -> str:
"""
Resolve a user-provided image or catalog label.
Parameters
----------
label : str or None
The label the user provided, or None if they did not provide one.
kind : str
Either ``"image"`` or ``"catalog"``; selects which registry the
label is resolved against and is used in error messages.
allow_new : bool, optional
If True the label is being resolved for a load operation, so an
explicit label need not already exist and a missing label
resolves to the single shared default label instead of raising.
Returns
-------
str
The resolved label.
Raises
------
ValueError
If an explicit label does not correspond to loaded data, or, when
no label is given, if nothing is loaded or if several labels
exist so the choice is ambiguous. Never raised when ``allow_new``
is True.
Notes
-----
This is needed so that the user gets what they expect in the simple
case where there is only one image or catalog loaded. In that case
the user may or may not have actually specified a label.
"""
registry, article = (
(self._images, "an image")
if kind == "image"
else (self._catalogs, "a catalog")
)
if label is not None:
if not allow_new and label not in registry:
raise ValueError(
f"{kind.capitalize()} label '{label}' not found. "
f"Please load {article} first."
)
return label
if allow_new:
# A load without an explicit label always targets the single
# shared default label, so repeated unlabeled loads replace the
# previous unlabeled image or catalog rather than piling up new
# ones.
return DEFAULT_LABEL
match len(registry):
case 0:
raise ValueError(f"No {kind} is loaded. Please load {article} first.")
case 1:
return list(registry)[0]
case _:
raise ValueError(
f"Multiple {kind} labels defined. Please specify a "
f"{kind}_label to select one."
)
def _resolve_catalog_label(
self, catalog_label: str | None, allow_new: bool = False
) -> str:
"""
Resolve a user-provided catalog label; see `_resolve_label`.
"""
return self._resolve_label(catalog_label, "catalog", allow_new=allow_new)
# Rendering hooks for backends
#
# The public API methods of this class are templates: they own all state
# handling and label resolution, then call the hooks below so that a
# backend can push the already-validated state into its display. Every
# hook receives a *resolved* label (never None, never "*"). All hooks are
# no-ops here, which keeps this class a valid headless reference
# implementation of the interface.
def _render_image(self, image_label: str) -> None:
"""
Display the image stored under ``image_label``.
Called by `load_image` after the image data, viewport, cuts, stretch
and colormap for the new image have all been stored and
``image_label`` has become the displayed image.
Parameters
----------
image_label : str
The resolved label of the image to display.
"""
def _apply_cuts(self, image_label: str) -> None:
"""
Push the stored cuts for ``image_label`` into the display.
Called by `set_cuts` and `load_image`, and only when ``image_label``
is one of the displayed images.
Parameters
----------
image_label : str
The resolved label of the image whose cuts changed.
"""
def _apply_stretch(self, image_label: str) -> None:
"""
Push the stored stretch for ``image_label`` into the display.
Called by `set_stretch` and `load_image`, and only when
``image_label`` is one of the displayed images.
Parameters
----------
image_label : str
The resolved label of the image whose stretch changed.
"""
def _apply_colormap(self, image_label: str) -> None:
"""
Push the stored colormap for ``image_label`` into the display.
Called by `set_colormap` and `load_image`, and only when
``image_label`` is one of the displayed images.
Parameters
----------
image_label : str
The resolved label of the image whose colormap changed.
"""
def _apply_viewport(self, image_label: str) -> None:
"""
Push the stored viewport for ``image_label`` into the display.
Called by `set_viewport` and `load_image`, and only when
``image_label`` is one of the displayed images.
Parameters
----------
image_label : str
The resolved label of the image whose viewport changed.
"""
def _draw_catalog(self, catalog_label: str) -> None:
"""
Draw (or redraw) the markers for the catalog ``catalog_label``.
Called by `load_catalog` and `set_catalog_style` after the catalog
data and style have been stored.
Parameters
----------
catalog_label : str
The resolved label of the catalog to draw.
"""
def _remove_catalog_marks(self, catalog_label: str) -> None:
"""
Remove the markers drawn for the catalog ``catalog_label``.
Called by `remove_catalog` after the catalog has been removed from
the stored state. `remove_catalog` expands ``"*"`` itself and calls
this hook once per removed catalog, so ``catalog_label`` is always
the label of a single catalog.
Parameters
----------
catalog_label : str
The resolved label of the catalog whose markers to remove.
"""
def _batch_update(self):
"""
Context manager wrapping a group of display updates.
`load_image` wraps its state changes and hook calls in this context
manager. Backends can override it to suppress intermediate redraws
(e.g. by holding widget synchronization) until the batch completes.
Returns
-------
context manager
By default `contextlib.nullcontext`, i.e. no batching.
"""
return contextlib.nullcontext()
@property
def _default_catalog_style(self) -> dict[str, Any]:
"""
The default style for the catalog markers.
"""
return {
"shape": "circle",
"color": "red",
"size": 5,
}
[docs]
def get_stretch(
self,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> BaseStretch:
image_label = self._resolve_image_label(image_label)
return self._images[image_label].stretch
[docs]
def set_stretch(
self,
stretch: BaseStretch,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> None:
if not isinstance(stretch, BaseStretch):
raise TypeError(
f"Stretch option {stretch} is not valid. Must be an "
"`astropy.visualization` Stretch object."
)
image_label = self._resolve_image_label(image_label)
self._images[image_label].stretch = stretch
if image_label in self._displayed_image_labels:
self._apply_stretch(image_label)
[docs]
def get_cuts(
self,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> BaseInterval:
image_label = self._resolve_image_label(image_label)
return self._images[image_label].cuts
[docs]
def set_cuts(
self,
cuts: tuple[numbers.Real, numbers.Real] | BaseInterval,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> None:
if isinstance(cuts, tuple) and len(cuts) == 2:
cuts = ManualInterval(cuts[0], cuts[1])
elif not isinstance(cuts, BaseInterval):
raise TypeError(
"Cuts must be an Astropy.visualization Interval object or a tuple "
"of two values."
)
image_label = self._resolve_image_label(image_label)
self._images[image_label].cuts = cuts
if image_label in self._displayed_image_labels:
self._apply_cuts(image_label)
@staticmethod
def _validate_colormap_name(map_name: str) -> None:
"""
Check that ``map_name`` is a valid matplotlib colormap name.
Parameters
----------
map_name : str
The name of the colormap to validate.
Raises
------
ValueError
If ``map_name`` is not the name of a matplotlib colormap.
Notes
-----
matplotlib is not a dependency of this package; if it is not
installed the name is accepted without validation.
"""
try:
from matplotlib import colormaps
except ImportError: # pragma: no cover
return
if map_name not in colormaps:
raise ValueError(f"Colormap '{map_name}' is not a valid colormap name.")
[docs]
def set_colormap(
self,
map_name: str,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> None:
self._validate_colormap_name(map_name)
image_label = self._resolve_image_label(image_label)
self._images[image_label].colormap = map_name
if image_label in self._displayed_image_labels:
self._apply_colormap(image_label)
[docs]
def get_colormap(
self,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> str | None:
image_label = self._resolve_image_label(image_label)
return self._images[image_label].colormap
# The methods, grouped loosely by purpose
[docs]
def get_catalog_style(
self,
catalog_label=None,
**kwargs, # noqa: ARG002
) -> dict[str, Any]:
if catalog_label is None and not self._catalogs:
# Nothing is loaded, so report the default style that a new
# catalog would get.
style = self._default_catalog_style
style["catalog_label"] = None
return style
catalog_label = self._resolve_catalog_label(catalog_label)
style = self._catalogs[catalog_label].style.copy()
style["catalog_label"] = catalog_label
return style
[docs]
def set_catalog_style(
self,
catalog_label: str | None = None,
shape: str = "circle",
color: str = "red",
size: float = 5,
**kwargs,
) -> None:
if not self._catalogs:
raise ValueError("Must load a catalog before setting a catalog style.")
catalog_label = self._resolve_catalog_label(catalog_label)
self._catalogs[catalog_label].style = dict(
shape=shape, color=color, size=size, **kwargs
)
self._draw_catalog(catalog_label)
# Methods for loading data
def _resolve_image_label(
self, image_label: str | None, allow_new: bool = False
) -> str:
"""
Resolve a user-provided image label; see `_resolve_label`.
"""
return self._resolve_label(image_label, "image", allow_new=allow_new)
[docs]
def load_image(
self,
data: str | os.PathLike | ArrayLike | NDData,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> None:
"""
Load a FITS file, 2D array or `~astropy.nddata.NDData` object into
the viewer and display it.
Parameters
----------
data : str, `os.PathLike`, array-like or `~astropy.nddata.NDData`
The data to load. A string or path is interpreted as the name
of a FITS file (or of an ASDF file when it ends in ``.asdf``).
image_label : str, optional
The label for the image. If not given, a single shared default
label is used, so loading an image without a label repeatedly
replaces the previously loaded unlabeled image.
**kwargs
Additional keyword arguments that may be used by the viewer.
Notes
-----
Loading an image sets an appropriate viewport for that image and
makes it the displayed image, replacing the image that was
displayed before.
Loading data under an ``image_label`` that already exists replaces
that label's data but keeps the cuts, stretch and colormap that
were set for the label; the viewport is reset to fit the new data.
An image loaded under a new label starts from the default settings.
"""
image_label = self._resolve_image_label(image_label, allow_new=True)
# When data is loaded under an existing label, that label keeps the
# cuts/stretch/colormap the user attached to it; the data, WCS and
# viewport come from the new image. A new label starts from the
# default settings.
previous = self._images.get(image_label)
previous_displayed = self._displayed_image_labels
with self._batch_update():
# Nothing is displayed while the new image's state is being set
# up, so the set_* calls made during initialization below do not
# fire any of the _apply_* hooks; the hooks are called once, in a
# fixed order, at the end of this method.
self._displayed_image_labels = ()
# Start from a fresh entry; the settings an existing label keeps
# are restored after loading.
self._images[image_label] = ViewportInfo()
try:
if isinstance(data, str | os.PathLike):
# os.fsdecode normalizes str, bytes and any os.PathLike
# (which need not be a pathlib.Path with a .suffix).
if os.fsdecode(data).endswith(".asdf"):
self._load_asdf(data, image_label)
else:
self._load_fits(data, image_label)
elif isinstance(data, NDData):
self._load_nddata(data, image_label)
else:
# Assume it is a 2D array
self._load_array(data, image_label)
except Exception:
# The load failed, so the viewer is still displaying whatever
# it displayed before. Put back the previous entry for this
# label (or remove the fresh one if the label was new) and
# the displayed-image tracking, so the _apply_* hooks keep
# firing for the image that is still on screen.
if previous is not None:
self._images[image_label] = previous
else:
del self._images[image_label]
self._displayed_image_labels = previous_displayed
raise
# Restore the settings this label already had, overriding the
# defaults set while loading.
if previous is not None:
self._images[image_label].cuts = previous.cuts
self._images[image_label].stretch = previous.stretch
self._images[image_label].colormap = previous.colormap
# The new image replaces whatever was displayed before.
self._displayed_image_labels = (image_label,)
self._render_image(image_label)
self._apply_cuts(image_label)
self._apply_stretch(image_label)
self._apply_colormap(image_label)
self._apply_viewport(image_label)
[docs]
def get_image(
self,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> ArrayLike | NDData | CCDData:
image_label = self._resolve_image_label(image_label)
return self._images[image_label].data
@property
def image_labels(self) -> tuple[str, ...]:
return tuple(self._images)
def _determine_largest_dimension(self, shape: tuple[int, int]) -> int:
"""
Determine which index is the largest dimension.
Parameters
----------
shape : tuple of int
The shape of the image.
Returns
-------
int
The index of the largest dimension of the image, or 0 if square.
"""
return int(shape[1] > shape[0])
def _initialize_image_viewport_stretch_cuts(
self,
image_data: ArrayLike | NDData | CCDData,
image_label: str | None,
) -> None:
"""
Initialize the viewport, stretch and cuts for an image.
Parameters
----------
image_data : ArrayLike
The image data to initialize the viewport for.
image_label : str or None
The label for the image. If None, the default label will be used.
Note
----
This method is called internally to set up the initial viewport,
stretch, and cuts for the image. It should be called AFTER setting
the WCS.
"""
# Deal with the viewport first
height, width = image_data.shape
# Center the image in the viewport and show the whole image.
# With 0-indexed pixel-center coordinates the center of the
# image is at ((width - 1) / 2, (height - 1) / 2).
center = ((width - 1) / 2, (height - 1) / 2)
fov = max(image_data.shape)
self._images[image_label].largest_dimension = self._determine_largest_dimension(
image_data.shape
)
wcs = self._images[image_label].wcs
# Is there a WCS set? If yes, make center a SkyCoord and fov a Quantity,
# otherwise leave them as pixels.
if wcs is not None:
center = wcs.pixel_to_world(center[0], center[1])
# largest_dimension indexes the numpy shape tuple (0 = y, 1 = x),
# but proj_plane_pixel_scales is ordered by WCS pixel axis
# (0 = x, 1 = y), so flip the index.
fov = (
fov
* u.degree
* proj_plane_pixel_scales(wcs)[
1 - self._images[image_label].largest_dimension
]
)
self.set_viewport(center=center, fov=fov, image_label=image_label)
# Now set the stretch and cuts
self.set_cuts(AsymmetricPercentileInterval(1, 95), image_label=image_label)
self.set_stretch(LinearStretch(), image_label=image_label)
def _load_fits(self, file: str | os.PathLike, image_label: str | None) -> None:
ccd = CCDData.read(file)
self._images[image_label].wcs = ccd.wcs
self._images[image_label].data = ccd
self._initialize_image_viewport_stretch_cuts(ccd.data, image_label)
def _load_array(self, array: ArrayLike, image_label: str | None) -> None:
"""
Load a 2D array into the viewer.
Parameters
----------
array : array-like
The array to load.
"""
self._images[image_label].wcs = None # No WCS for raw arrays
self._images[image_label].largest_dimension = self._determine_largest_dimension(
array.shape
)
self._images[image_label].data = array
self._initialize_image_viewport_stretch_cuts(array, image_label)
def _load_nddata(self, data: NDData, image_label: str | None) -> None:
"""
Load an `astropy.nddata.NDData` object into the viewer.
Parameters
----------
data : `astropy.nddata.NDData`
The NDData object to load.
"""
self._images[image_label].wcs = data.wcs
self._images[image_label].data = data
self._images[image_label].largest_dimension = self._determine_largest_dimension(
data.data.shape
)
# Not all NDDData objects have a shape, apparently
self._initialize_image_viewport_stretch_cuts(data.data, image_label)
def _load_asdf(self, asdf_file: str | os.PathLike, image_label: str | None) -> None:
"""
Not implementing some load types is fine.
"""
raise NotImplementedError(
"ASDF loading is not implemented in this dummy viewer."
)
# Saving contents of the view and accessing the view
[docs]
def save(
self,
filename: str | os.PathLike,
overwrite: bool = False,
**kwargs, # noqa: ARG002
) -> None:
p = Path(filename)
if p.exists() and not overwrite:
raise FileExistsError(
f"File {filename} already exists. Use overwrite=True to overwrite it."
)
p.write_text("This is a dummy file. The viewer does not save anything.")
# Marker-related methods
def _catalog_conversion_wcs(self, conversion_required: bool) -> WCS | None:
"""
Return the WCS to use for catalog pixel↔sky coordinate conversion.
Parameters
----------
conversion_required : bool
Whether a pixel↔sky conversion is required to satisfy the
``load_catalog`` call, as opposed to being merely opportunistic.
Returns
-------
`astropy.wcs.WCS` or None
The WCS of the single loaded or single displayed image, or
None.
Raises
------
ValueError
If a conversion is required, several images are loaded, and
the displayed image does not disambiguate the choice.
Notes
-----
The WCS is chosen with the same defaulting rule used for labels
when no label is given: if exactly one image is loaded, that
image's WCS is used. With no image loaded there is no WCS. With
several images loaded, the image the viewer is currently
displaying disambiguates: if exactly one image is displayed, its
WCS is used. Otherwise the choice is ambiguous, so if a
conversion is actually required an error is raised; if not, no
WCS is used, i.e. the optional enrichment of the catalog with
the coordinates that are not in the table is skipped rather than
done with an arbitrary image's WCS.
"""
match len(self._images):
case 0:
return None
case 1:
return list(self._images.values())[0].wcs
case _:
if len(self._displayed_image_labels) == 1:
return self._images[self._displayed_image_labels[0]].wcs
if conversion_required:
raise ValueError(
"Multiple image labels defined. Cannot determine "
"which image's WCS to use to convert catalog "
"coordinates."
)
return None
[docs]
def load_catalog(
self,
table: Table,
x_colname: str = "x",
y_colname: str = "y",
skycoord_colname: str = "coord",
use_skycoord: bool = False,
catalog_label: str | None = None,
catalog_style: dict | None = None,
**kwargs, # noqa: ARG002
) -> None:
try:
coords = table[skycoord_colname]
except KeyError:
coords = None
try:
xy = (table[x_colname], table[y_colname])
except KeyError:
xy = None
# A conversion is required, not just opportunistic, when the pixel
# columns must be computed from the sky coordinates or when sky
# coordinates were requested but are not in the table.
wcs = self._catalog_conversion_wcs(
conversion_required=(xy is None and coords is not None)
or (coords is None and use_skycoord)
)
to_add = table.copy()
if xy is None:
if wcs is not None and coords is not None:
x, y = wcs.world_to_pixel(coords)
to_add[x_colname] = x
to_add[y_colname] = y
xy = (x, y)
else:
to_add[x_colname] = to_add[y_colname] = None
if not use_skycoord and xy is None:
raise ValueError(
"Cannot use pixel coordinates without pixel columns or both "
"coordinates and a WCS."
)
if coords is None:
if use_skycoord and wcs is None:
raise ValueError(
"Cannot use sky coordinates without a SkyCoord column or WCS."
)
elif xy is not None and wcs is not None:
# If we have xy coordinates, convert them to sky coordinates
coords = wcs.pixel_to_world(xy[0], xy[1])
to_add[skycoord_colname] = coords
else:
to_add[skycoord_colname] = None
# Store the position columns under canonical internal names so that
# get_catalog can return them under any requested names.
to_add.rename_columns(
[x_colname, y_colname, skycoord_colname], ["x", "y", "coord"]
)
catalog_label = self._resolve_catalog_label(catalog_label, allow_new=True)
if catalog_label not in self._catalogs:
self._catalogs[catalog_label] = CatalogInfo()
# Set the new data
self._catalogs[catalog_label].data = to_add
# Ensure a catalog always has a style
if catalog_style is None:
if not self._catalogs[catalog_label].style:
# No style has been set, so use the default style
catalog_style = self._default_catalog_style.copy()
else:
# Use the existing style
catalog_style = self._catalogs[catalog_label].style.copy()
self._catalogs[catalog_label].style = catalog_style
self._draw_catalog(catalog_label)
[docs]
def remove_catalog(
self,
catalog_label: str | None = None,
**kwargs, # noqa: ARG002
) -> None:
if isinstance(catalog_label, list):
raise TypeError(
"Cannot remove multiple catalogs from a list. Please specify "
"a single catalog label or use '*' to remove all catalogs."
)
elif catalog_label == "*":
# If the user wants to remove all catalogs, we reset the
# catalogs dictionary to an empty one, which is exactly the
# state a fresh viewer starts in. The "*" is expanded here,
# so the _remove_catalog_marks hook is called once per catalog
# and never sees the "*" itself.
removed_labels = tuple(self._catalogs)
self._catalogs = {}
with self._batch_update():
for removed_label in removed_labels:
self._remove_catalog_marks(removed_label)
return
# Special cases are done, so we can resolve the catalog label.
# Resolution raises a ValueError if the label is not found.
catalog_label = self._resolve_catalog_label(catalog_label)
del self._catalogs[catalog_label]
self._remove_catalog_marks(catalog_label)
[docs]
def get_catalog(
self,
x_colname: str = "x",
y_colname: str = "y",
skycoord_colname: str = "coord",
catalog_label: str | None = None,
**kwargs, # noqa: ARG002
) -> Table:
# Docstring is copied from the interface definition, so it is not
# duplicated here.
if catalog_label is None and not self._catalogs:
# Nothing is loaded; return an empty table with the expected
# columns rather than raising, so that "is there anything
# here?" queries are easy to write.
return Table(names=[x_colname, y_colname, skycoord_colname])
catalog_label = self._resolve_catalog_label(catalog_label)
# Return a copy so that modifying the returned table cannot corrupt
# the stored catalog, renamed from the canonical internal column
# names to the requested ones.
result = self._catalogs[catalog_label].data.copy()
result.rename_columns(
["x", "y", "coord"], [x_colname, y_colname, skycoord_colname]
)
return result
@property
def catalog_labels(self) -> tuple[str, ...]:
return tuple(self._catalogs)
# Methods that modify the view
[docs]
def set_viewport(
self,
center: SkyCoord | tuple[numbers.Real, numbers.Real] | None = None,
fov: Quantity | numbers.Real | None = None,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> None:
image_label = self._resolve_image_label(image_label)
# Get current center/fov, if any, so that the user may input only one of them
# after the initial setup if they wish.
current_viewport = copy(self._images[image_label])
if center is None:
center = current_viewport.center
if fov is None:
fov = current_viewport.fov
# If either center or fov is None these checks will raise an appropriate error
if not isinstance(center, SkyCoord | tuple):
raise TypeError(
"Invalid value for center. Center must be a SkyCoord or tuple "
"of (X, Y)."
)
if not isinstance(fov, Quantity | numbers.Real):
raise TypeError(
"Invalid value for fov. fov must be an angular Quantity or float."
)
if isinstance(fov, Quantity) and not fov.unit.is_equivalent(u.deg):
raise u.UnitTypeError(
"Incorrect unit for fov. fov must be an angular Quantity or float."
)
# Check that the center and fov are compatible with the current image
if self._images[image_label].wcs is None:
if current_viewport.center is not None:
# If there is a WCS either input is fine. If there is no WCS then we
# only check wther the new center is the same type as the
# current center.
if isinstance(center, SkyCoord) and not isinstance(
current_viewport.center, SkyCoord
):
raise TypeError(
"Center must be a tuple for this image when WCS is not set."
)
elif isinstance(center, tuple) and not isinstance(
current_viewport.center, tuple
):
raise TypeError(
"Center must be a SkyCoord for this image when WCS is not set."
)
if current_viewport.fov is not None:
if isinstance(fov, Quantity) and not isinstance(
current_viewport.fov, Quantity
):
raise TypeError(
"FOV must be a float for this image when WCS is not set."
)
elif isinstance(fov, numbers.Real) and not isinstance(
current_viewport.fov, numbers.Real
):
raise TypeError(
"FOV must be a float for this image when WCS is not set."
)
# 😅 if we made it this far we should be able to handle the actual setting
self._images[image_label].center = center
self._images[image_label].fov = fov
if image_label in self._displayed_image_labels:
self._apply_viewport(image_label)
[docs]
def get_viewport(
self,
sky_or_pixel: str | None = None,
image_label: str | None = None,
**kwargs, # noqa: ARG002
) -> dict[str, Any]:
if sky_or_pixel not in (None, "sky", "pixel"):
raise ValueError("sky_or_pixel must be 'sky', 'pixel', or None.")
image_label = self._resolve_image_label(image_label)
viewport = self._images[image_label]
# Figure out what to return if the user did not specify sky_or_pixel.
# The interface definition for get_viewport says that if the image has a WCS,
# then the return should be in world coordinates, otherwise it should
# be in pixel coordinates.
if sky_or_pixel is None:
if viewport.wcs is not None:
# The requested image has a WCS, so return sky coordinates
sky_or_pixel = "sky"
else:
# The requested image has no WCS, so return pixel coordinates
sky_or_pixel = "pixel"
center = None
fov = None
if sky_or_pixel == "sky":
if isinstance(viewport.center, SkyCoord):
center = viewport.center
if isinstance(viewport.fov, Quantity):
fov = viewport.fov
if center is None or fov is None:
# At least one of center or fov is not set, which means at least one
# was not already sky, so we need to convert them or fail
if viewport.wcs is None:
raise ValueError(
"WCS is not set. Cannot convert pixel coordinates to "
"sky coordinates."
)
else:
if center is None:
center = viewport.wcs.pixel_to_world(
viewport.center[0], viewport.center[1]
)
if fov is None:
# largest_dimension indexes the numpy shape tuple
# (0 = y, 1 = x), but proj_plane_pixel_scales is
# ordered by WCS pixel axis (0 = x, 1 = y), so flip
# the index.
pixel_scale = proj_plane_pixel_scales(viewport.wcs)[
1 - viewport.largest_dimension
]
fov = pixel_scale * viewport.fov * u.degree
else:
# Pixel coordinates
if isinstance(viewport.center, SkyCoord):
if viewport.wcs is None:
raise ValueError(
"WCS is not set. Cannot convert sky coordinates to "
"pixel coordinates."
)
center = viewport.wcs.world_to_pixel(viewport.center)
else:
center = viewport.center
if isinstance(viewport.fov, Quantity):
if viewport.wcs is None:
raise ValueError(
"WCS is not set. Cannot convert FOV to pixel coordinates."
)
# See comment above about flipping the index into
# proj_plane_pixel_scales.
pixel_scale = proj_plane_pixel_scales(viewport.wcs)[
1 - viewport.largest_dimension
]
# proj_plane_pixel_scales returns degrees for a celestial WCS
# (wcslib normalizes CUNIT to degrees), so convert the fov to
# degrees rather than assuming it already is in degrees.
fov = viewport.fov.to_value(u.degree) / pixel_scale
else:
fov = viewport.fov
return dict(center=center, fov=fov, image_label=image_label)
[docs]
def docs_from_image_viewer_logic_if_missing(cls):
"""
Class decorator that fills in missing docstrings from `ImageViewerLogic`.
Public methods of the decorated class that lack a docstring receive the
docstring of the same-named method on
`~astro_image_display_api.image_viewer_logic.ImageViewerLogic`. Backends
that override interface methods can use this so that the overrides keep
the documented API without duplicating the docstrings.
Parameters
----------
cls : type
The class being decorated.
Returns
-------
type
The same class, with missing docstrings filled in.
"""
return _copy_missing_docs(cls, ImageViewerLogic)