diff --git a/carta/color_blending.py b/carta/color_blending.py new file mode 100644 index 0000000..5bd1106 --- /dev/null +++ b/carta/color_blending.py @@ -0,0 +1,553 @@ +"""This module contains functionality for interacting with color blending images and their layers.""" + +from .constants import Colormap, ColormapSet, ImageType, SpatialAxis +from .image import Image +from .view import View +from .util import ( + BasePathMixin, + CartaScriptingException, + CartaValidationFailed, + Macro, +) +from .validation import ( + Boolean, + Constant, + Coordinate, + InstanceOf, + IterableOf, + Number, + NoneOr, + Size, + Attr, + Evaluate, + validate, +) + + +class Layer(BasePathMixin): + """This object represents a single layer in a color blending object. + + Parameters + ---------- + color_blending : :obj:`carta.color_blending.ColorBlending` + The color blending object. + layer_id : integer + The layer ID. + + Attributes + ---------- + color_blending : :obj:`carta.color_blending.ColorBlending` + The color blending object. + layer_id : integer + The layer ID. + session : :obj:`carta.session.Session` + The session object associated with this layer. + """ + + def __init__(self, color_blending, layer_id): + self.color_blending = color_blending + self.layer_id = layer_id + self.session = color_blending.session + + self._base_path = f"{self.color_blending._base_path}.frames[{layer_id}]" + self._frame = Macro("", self._base_path) + + @classmethod + def from_list(cls, color_blending, layer_ids): + """Create a list of Layer objects from a list of layer IDs. + + Parameters + ---------- + color_blending : :obj:`carta.color_blending.ColorBlending` + The color blending object. + layer_ids : list of integer + The layer IDs. + + Returns + ------- + list of :obj:`carta.color_blending.Layer` + A list of new Layer objects. + """ + return [cls(color_blending, layer_id) for layer_id in layer_ids] + + @property + def view_index(self): + """The view index of this layer's underlying image. + + This is the position of the underlying image in the session's views. + A layer does not occupy its own position in the views; its parent + color blending does (see + :obj:`carta.color_blending.ColorBlending.view_index`). + + Returns + ------- + integer + The view index of the underlying image. + + Raises + ------ + RuntimeError + If no matching image entry exists in the views. + """ + return self.session._find_view_index(ImageType.FRAME, self.image_id) + + def __repr__(self): + """A human-readable representation of this layer.""" + cls = type(self).__name__ + cb_id = self.color_blending.color_blending_id + + try: + index = self.view_index + except (CartaScriptingException, RuntimeError): + return ( + f"[Closed] {cls}(view_index=None, " + f"color_blending_id={cb_id}, layer_id={self.layer_id})" + ) + + try: + name = self.file_name + colormap = self.colormap + inverted = self.inverted + alpha = self.alpha + except CartaScriptingException: + return ( + f"[Closed] {cls}(view_index={index}, " + f"color_blending_id={cb_id}, layer_id={self.layer_id})" + ) + + return ( + f"{cls}(view_index={index}, color_blending_id={cb_id}, " + f"layer_id={self.layer_id}, file_name={name!r}, " + f"colormap={colormap!r}, inverted={inverted!r}, " + f"alpha={alpha!r})" + ) + + @property + def file_name(self): + """The name of the image. + + Returns + ------- + string + The image name. + """ + return self.get_value("frameInfo.fileInfo.name") + + @property + def image_id(self): + """The frontend image id of the layer's underlying image. + + Returns + ------- + integer + The image id. + """ + return self.get_value("frameInfo.fileId") + + @property + def colormap(self): + """The colormap used to render this layer. + + Returns + ------- + string + The colormap name. + """ + return self.get_value("renderConfig.colorMap") + + @property + def inverted(self): + """Whether the layer's colormap is inverted. + + Returns + ------- + boolean + Whether the colormap is inverted. + """ + return self.get_value("renderConfig.isInverted") + + @property + def alpha(self): + """The alpha value of this layer in the color blending. + + Returns + ------- + number + The alpha value, between 0 and 1. + """ + return self.color_blending.get_value(f"alpha[{self.layer_id}]") + + def delete(self): + """Delete this layer from its parent color blending.""" + self.color_blending.delete_layer(self.layer_id) + + @validate(InstanceOf(Image)) + def set_image(self, image): + """Set the image for this layer. + + Parameters + ---------- + image : {0} + The image to set. + """ + self.color_blending.set_layer_image(self.layer_id, image) + + @validate(Number(0, 1)) + def set_alpha(self, alpha): + """Set the alpha value for the layer in the color blending. + + Parameters + ---------- + alpha : {0} + The alpha value. + """ + self.color_blending.call_action("setAlpha", self.layer_id, alpha) + + @validate(Constant(Colormap), Boolean()) + def set_colormap(self, colormap, invert=False): + """Set the colormap for the layer in the color blending. + + Parameters + ---------- + colormap : {0} + The colormap. + invert : {1} + Whether the colormap should be inverted. This is false by default. + """ + self.call_action("renderConfig.setColorMap", colormap) + self.call_action("renderConfig.setInverted", invert) + + +class ColorBlending(View, BasePathMixin): + """This object represents a color blending image in a session. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object associated with this color blending. + color_blending_id : integer + The id of the backing ``ColorBlendingStore`` on the frontend. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this color blending. + color_blending_id : integer + The id of the backing ``ColorBlendingStore`` on the frontend. + """ + + VIEW_TYPE = ImageType.COLOR_BLENDING + + def __init__(self, session, color_blending_id): + super().__init__(session) + self.color_blending_id = color_blending_id + + path = "imageViewConfigStore.colorBlendingImageMap" + self._base_path = f"{path}[{self.color_blending_id}]" + self._frame = Macro("", self._base_path) + + @property + def _stable_id(self): + return self.color_blending_id + + def __repr__(self): + """A human-readable representation of this color blending object.""" + cls = type(self).__name__ + + try: + index = self.view_index + except (CartaScriptingException, RuntimeError): + return ( + f"[Closed] {cls}(view_index=None, " + f"color_blending_id={self.color_blending_id})" + ) + + try: + name = self.file_name + except CartaScriptingException: + return ( + f"[Closed] {cls}(view_index={index}, " + f"color_blending_id={self.color_blending_id})" + ) + + return ( + f"{cls}(view_index={index}, " + f"color_blending_id={self.color_blending_id}, " + f"file_name={name!r})" + ) + + # METADATA + + @property + def _base_frame(self): + return Image(self.session, self.get_value("frames[0].id")) + + @property + def file_name(self): + """The name of the image. + + Returns + ------- + string + The image name. + """ + return self.get_value("filename") + + # LAYERS + + @property + def alphas(self): + """The alpha value list for the color blending layers. + + Returns + ------- + list of float + The alpha values. + """ + return self.get_value("alpha") + + @property + def depth(self): + """The number of layers in the color blending. + + Returns + ------- + integer + The number of layers. + """ + return self.get_value("frames.length") + + @validate( + Evaluate(IterableOf, Number(0, 1), Attr("depth"), Attr("depth")) + ) + def set_alphas(self, alpha_list): + """Set the alpha value for the color blending layers. + + Parameters + ---------- + alpha_list : {0} + The alpha values. + """ + for alpha, layer in zip(alpha_list, self.layers()): + layer.set_alpha(alpha) + + @validate( + NoneOr(Evaluate(IterableOf, Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1))) + ) + def layers(self, layer_ids=None): + """Return all or selected Layer objects for this color blending. + + When no layer IDs are supplied, all layers are returned. When layer + IDs are supplied, the layers are returned in the requested order. + Duplicate layer IDs are preserved. + + Parameters + ---------- + layer_ids : {0} + The layer IDs to return. By default, all layers are returned. + + Returns + ------- + list of :obj:`carta.color_blending.Layer` + The requested layers. + """ + if layer_ids is None: + layer_ids = range(self.depth) + return Layer.from_list(self, layer_ids) + + @validate(InstanceOf(Image)) + def add_layer(self, image): + """Add a new layer to the color blending. + + Parameters + ---------- + image : :obj:`carta.image.Image` + The image to add. + """ + self.call_action("addSelectedFrame", image._frame) + + @validate(Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1)) + def delete_layer(self, layer_index): + """Delete a layer from the color blending. + + Parameters + ---------- + layer_index : {0} + The layer index. If the base layer (layer_index = 0) is deleted, + the next layer becomes the spatial reference. If it is the only + layer, the color blending is closed. + """ + if layer_index == 0: + layers = self.layers() + if len(layers) == 1: + self.close() + return + + Image(self.session, layers[1].image_id).make_spatial_reference() + return + self.call_action("deleteSelectedFrame", layer_index - 1) + + @validate( + Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1), + InstanceOf(Image), + ) + def set_layer_image(self, layer_index, image): + """Set the image for a layer at a specified index in the color blending. + + Parameters + ---------- + layer_index : {0} + The layer index. If the base layer (layer_index = 0) is selected, + ``image`` becomes the new spatial reference. Otherwise, the + specified secondary layer is replaced. + image : {1} + The image to set. + + Raises + ------ + CartaValidationFailed + If ``image`` is already present in this color blending. + """ + image_id = image.image_id + if any(layer.image_id == image_id for layer in self.layers()): + raise CartaValidationFailed( + f"Image with image_id={image_id} is already in the " + "color blending layers." + ) + + if layer_index == 0: + image.set_spatial_matching(True) + image.make_spatial_reference() + return + self.call_action("setSelectedFrame", layer_index - 1, image._frame) + + # NAVIGATION + + @validate(Coordinate(), Coordinate()) + def set_center(self, x, y): + """Set the center position, in image or world coordinates. + + World coordinates are interpreted according to the session's globally + set coordinate system and any custom number formats. These can be + changed using + :obj:`carta.session.wcs.global_.set_coordinate_system` and + :obj:`carta.session.wcs.numbers.set_format`. + + Coordinates must either both be image coordinates or match the current + number formats. Numbers are interpreted as image coordinates, and + numeric strings with no units are interpreted as degrees. + + Parameters + ---------- + x : {0} + The X position. + y : {1} + The Y position. + + Raises + ------ + ValueError + If a mix of image and world coordinates is provided, if world + coordinates are provided and the image has no valid WCS + information, or if world coordinates do not match the session-wide + number formats. + """ + self._base_frame.set_center(x, y) + + @validate(Size(), Constant(SpatialAxis)) + def zoom_to_size(self, size, axis): + """Zoom to the given size along the specified axis. + + Numbers are interpreted as pixel sizes. Numeric strings with no units are interpreted as arcseconds. + + Parameters + ---------- + size : {0} + The size to zoom to. + axis : {1} + The spatial axis to use. + + Raises + ------ + ValueError + If an angular size is provided and the image has no valid WCS information. + """ + self._base_frame.zoom_to_size(size, axis) + + @validate(Number(), Boolean()) + def set_zoom_level(self, zoom, absolute=True): + """Set the zoom level. + + TODO: explain this more rigorously. + + Parameters + ---------- + zoom : {0} + The zoom level. + absolute : {1} + Whether the zoom level should be treated as absolute. By default + it is adjusted by a scaling factor. + """ + self._base_frame.set_zoom_level(zoom, absolute) + + # RENDERING + + @validate(Constant(ColormapSet)) + def set_colormap_set(self, colormap_set): + """Set the colormap set for the color blending. + + Parameters + ---------- + colormap_set : {0} + The colormap set. + """ + self.call_action("applyColormapSet", colormap_set) + + # VISIBILITY + + @validate(Boolean()) + def set_raster_visible(self, state): + """Set the raster component visibility. + + Parameters + ---------- + state : {0} + The desired visibility state. + """ + is_visible = self.get_value("rasterVisible") + if is_visible != state: + self.call_action("toggleRasterVisible") + + @validate(Boolean()) + def set_contour_visible(self, state): + """Set the contour component visibility. + + Parameters + ---------- + state : {0} + The desired visibility state. + """ + is_visible = self.get_value("contourVisible") + if is_visible != state: + self.call_action("toggleContourVisible") + + @validate(Boolean()) + def set_vector_overlay_visible(self, state): + """Set the vector overlay visibility. + + Parameters + ---------- + state : {0} + The desired visibility state. + """ + is_visible = self.get_value("vectorOverlayVisible") + if is_visible != state: + self.call_action("toggleVectorOverlayVisible") + + # CLOSE + + def close(self): + """Close this color blending object.""" + self.session.call_action( + "imageViewConfigStore.removeColorBlending", self._frame + ) diff --git a/carta/constants.py b/carta/constants.py index b5d0fbd..87dc673 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -23,6 +23,20 @@ class ComplexComponent(StrEnum): Colormap.__doc__ = """All available colormaps.""" +class ColormapSet(StrEnum): + """Colormap sets for color blending.""" + RGB = "RGB" + CMY = "CMY" + RAINBOW = "Rainbow" + + +class ImageType(IntEnum): + """View item types, corresponding to the frontend ImageType enum.""" + FRAME = 0 + COLOR_BLENDING = 1 + PV_PREVIEW = 2 + + Scaling = IntEnum('Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA'), start=0) Scaling.__doc__ = """Colormap scaling types.""" diff --git a/carta/image.py b/carta/image.py index d80c696..6c7fade 100644 --- a/carta/image.py +++ b/carta/image.py @@ -4,12 +4,12 @@ """ -from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin, Point as Pt +from .constants import ImageType, Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit +from .view import View +from .util import Macro, cached, BasePathMixin, CartaScriptingException, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point from .metadata import parse_header - from .raster import Raster from .contours import Contours from .vector_overlay import VectorOverlay @@ -17,7 +17,7 @@ from .region import RegionSet -class Image(BasePathMixin): +class Image(View, BasePathMixin): """This object corresponds to an image open in a CARTA frontend session. This class should not be instantiated directly. Instead, use the session object's methods for opening new images or retrieving existing images. @@ -47,8 +47,10 @@ class Image(BasePathMixin): Functions for manipulating regions associated with this image. """ + VIEW_TYPE = ImageType.FRAME + def __init__(self, session, image_id): - self.session = session + super().__init__(session) self.image_id = image_id self._base_path = f"frameMap[{image_id}]" @@ -61,6 +63,10 @@ def __init__(self, session, image_id): self.wcs = ImageWCSOverlay(self) self.regions = RegionSet(self) + @property + def _stable_id(self): + return self.image_id + @classmethod def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_active=True, update_directory=False): """Open or append a new image in the session and return an image object associated with it. @@ -102,29 +108,23 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ image_id = session.call_action(command, *params, return_path="frameInfo.fileId") return cls(session, image_id) - @classmethod - def from_list(cls, session, image_list): - """Create a list of image objects from a list of open images retrieved from the frontend. - - This method should not be used directly. It is wrapped by :obj:`carta.session.Session.image_list`. + def __repr__(self): + """A human-readable representation of this image object.""" + cls = type(self).__name__ + cached_name = getattr(self, "_cache", {}).get("file_name") + name_part = f", file_name={cached_name!r}" if cached_name is not None else "" - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - image_list : list of dicts - The JSON object representing frame names retrieved from the frontend. + try: + index = self.view_index + except (CartaScriptingException, RuntimeError): + return f"[Closed] {cls}(view_index=None{name_part}, image_id={self.image_id})" - Returns - ------- - list of :obj:`carta.image.Image` - A list of new image objects. - """ - return [cls(session, f["value"]) for f in image_list] + try: + name = self.file_name + except CartaScriptingException: + return f"[Closed] {cls}(view_index={index}{name_part}, image_id={self.image_id})" - def __repr__(self): - """A human-readable representation of this image object.""" - return f"{self.session.session_id}:{self.image_id}:{self.file_name}" + return f"{cls}(view_index={index}, file_name={name!r}, image_id={self.image_id})" # METADATA @@ -254,13 +254,9 @@ def polarizations(self): # SELECTION - def make_active(self): - """Make this the active image.""" - self.session.call_action("setActiveFrameById", self.image_id) - def make_spatial_reference(self): """Make this image the spatial reference.""" - self.session.call_action("setSpatialReference", self._frame) + self.session.call_action("setSpatialReference", self._frame, False) @validate(Boolean()) def set_spatial_matching(self, state): @@ -362,7 +358,7 @@ def valid_wcs(self): def set_center(self, x, y): """Set the center position, in image or world coordinates. - World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.session.set_coordinate_system` and :obj:`set_custom_number_format`. + World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.session.wcs.global_.set_coordinate_system` and :obj:`carta.session.wcs.numbers.set_format`. Coordinates must either both be image coordinates or match the current number formats. Numbers are interpreted as image coordinates, and numeric strings with no units are interpreted as degrees. diff --git a/carta/session.py b/carta/session.py index fc0ddc8..615d52b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,11 +10,13 @@ import posixpath from .image import Image -from .constants import PanelMode, GridMode, ComplexComponent, Polarization +from .view import View +from .color_blending import ColorBlending +from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt -from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union +from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt +from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay from .raster import SessionRaster @@ -50,6 +52,8 @@ class Session: ---------- session_id : integer The ID of the CARTA frontend session associated with this object. + carta_version : string + The CARTA version string reported by the frontend. wcs : :obj:`carta.wcs_overlay.SessionWCSOverlay` Sub-object with functions related to the WCS overlay. raster : :obj:`carta.raster.SessionRaster` @@ -232,7 +236,27 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa def __repr__(self): """A human-readable representation of this session object.""" - return f"Session(session_id={self.session_id}, uri={self._protocol.frontend_url if self._protocol else None})" + uri = self._protocol.frontend_url if self._protocol else None + try: + version = self.carta_version + except (AttributeError, CartaScriptingException): + return f"Session(session_id={self.session_id}, uri={uri!r})" + + return f"Session(session_id={self.session_id}, uri={uri!r}, carta_version={version!r})" + + # METADATA + + @property + @cached + def carta_version(self): + """The CARTA version. + + Returns + ------- + string + The version string reported by the frontend. + """ + return self.get_value("frontendVersion") def call_action(self, path, *args, **kwargs): """Call an action on the frontend through the backend's scripting interface. @@ -510,43 +534,328 @@ def open_hypercube(self, image_paths, append=False): image_id = self.call_action(command, stokes_images, output_directory, output_hdu) return Image(self, image_id) - def image_list(self): - """Return the list of currently open images. + @validate(IterableOf(String(), min_size=1)) + def open_as_color_blending(self, files): + """Open files and combine them into a new color blending image. + + This helper always opens the files with ``append=False``, which + closes any currently open images before opening ``files``. It + then makes the first opened image the spatial reference, enables + spatial matching for the remaining opened images, and calls + :obj:`create_color_blending`. + + Parameters + ---------- + files : {0} + The files to be blended. Returns ------- - list of :obj:`carta.image.Image` objects - The list of images open in this session. + :obj:`carta.color_blending.ColorBlending` + The new color blending object. """ - return Image.from_list(self, self.get_value("frameNames")) + images = self.open_images(files, append=False) + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + cb = self.create_color_blending() + return cb + + # VIEWS + + @validate(NoneOr(IterableOf(Number.ID))) + def views(self, view_indices=None): + """Return all or selected currently open views. + + When no indices are supplied, all open views are returned. When + indices are supplied, the views at those positions are returned in + the requested order. - def active_frame(self): - """Return the currently active image. + Unsupported view types are skipped when no indices are supplied. Returns ------- - :obj:`carta.image.Image` - The currently active image. + list of :obj:`carta.view.View` + The requested heterogeneous views open in this session. + + Raises + ------ + IndexError + If any requested view index is out of range. + NotImplementedError + If an explicitly requested view type is unsupported. + CartaValidationFailed + If ``view_indices`` contains an invalid value. """ - image_id = self.get_value("activeFrame.frameInfo.fileId") - return Image(self, image_id) + summary = self.get_value("imageViewConfigStore.imageListSummary") + remote_indices = range(len(summary)) - def image_by_id(self, image_id): - """Return an image object with the specified ID. + if view_indices is None: + view_indices = remote_indices + skip_unsupported = True + else: + missing_indices = [i for i in view_indices if i not in remote_indices] + if missing_indices: + raise IndexError(f"No views with indices {missing_indices} are open.") + skip_unsupported = False + + views = [] + for index in view_indices: + entry = summary[index] + try: + views.append(View.view_class(entry["type"])(self, entry["id"])) + except (CartaValidationFailed, NotImplementedError): + if not skip_unsupported: + raise + view_type = ImageType(entry["type"]) + print(f"Skipping unsupported {view_type.name} view at index {index}.") + return views + + @validate(NoneOr(IterableOf(Number.ID))) + def images(self, image_ids=None): + """Return images from the session. + + When no IDs are supplied, all open images are returned. When IDs are + supplied, they are validated against the session's image map. - This is a helper function which constructs a :obj:`carta.image.Image` object with the specified ID, without checking whether an image with that ID is currently open. It is the caller's responsibility to ensure this. + Parameters + ---------- + image_ids : {0} + The image IDs to return. By default, all open images are returned. + + Returns + ------- + list of :obj:`carta.image.Image` + The requested images. + """ + remote_ids = [i["value"] for i in self.get_value("frameNames")] + + if image_ids is None: + image_ids = remote_ids + elif missing_ids := [i for i in image_ids if i not in remote_ids]: + raise RuntimeError(f"No images with image_ids {missing_ids} are open.") + return [Image(self, i) for i in image_ids] + + @validate(NoneOr(IterableOf(Number.ID))) + def color_blendings(self, color_blending_ids=None): + """Return color blending images from the session. + + When no IDs are supplied, all open color blending images are + returned. When IDs are supplied, they are validated against the + session's color blending map. Parameters ---------- - image_id : integer - The ID of the image to return. + color_blending_ids : {0} + The IDs of the color blending images to return. By default, all + open color blending images are returned. Returns ------- - :obj:`carta.image.Image` - The image with the specified ID. + list of :obj:`carta.color_blending.ColorBlending` + The requested color blending images. """ - return Image(self, image_id) + summary = self.get_value("imageViewConfigStore.imageListSummary") + remote_ids = [i["id"] for i in summary if i["type"] == ImageType.COLOR_BLENDING] + + if color_blending_ids is None: + color_blending_ids = remote_ids + elif missing_ids := [i for i in color_blending_ids if i not in remote_ids]: + raise RuntimeError(f"No color blendings with color_blending_ids {missing_ids} are open.") + return [ColorBlending(self, i) for i in color_blending_ids] + + def _find_view_index(self, view_type, stable_id): + """Return the view index of an item identified by a stable id. + + Parameters + ---------- + view_type : :obj:`carta.constants.ImageType` + The view type. + stable_id : integer + The stable id for that type (``image_id`` for images, + ``color_blending_id`` for color blendings). + + Returns + ------- + integer + The view index of the matching entry. + + Raises + ------ + RuntimeError + If no matching entry exists in the views. + """ + view_index = self.call_action( + "imageViewConfigStore.getImageListIndex", + view_type, + stable_id, + response_expected=True, + ) + if view_index == -1: + raise RuntimeError(f"Could not find a view of type {view_type!r} with id {stable_id} in the views.") + return view_index + + @validate(NoneOr(Number.ID), NoneOr(Number.ID), NoneOr(Number.ID)) + def view_by_id(self, *, view_index=None, image_id=None, color_blending_id=None): + """Return the view identified by exactly one supported identifier. + + Parameters + ---------- + view_index : integer, optional + The index of the item in the views. + Returns whichever concrete wrapper (:obj:`carta.image.Image` + or :obj:`carta.color_blending.ColorBlending`) matches the + entry type at that position. Raises :obj:`NotImplementedError` + for any future entry type that is not yet wrapped on the + Python side. + image_id : integer, optional + The stable id of an image. + color_blending_id : integer, optional + The stable id of a color blending. + + Returns + ------- + :obj:`carta.view.View` + The matching view. + + Raises + ------ + ValueError + If zero or more than one of the keyword arguments is provided. + The error message lists the three accepted keyword names so + the API is discoverable from the exception alone. + IndexError + If ``view_index`` is out of range. + RuntimeError + If no matching entry exists for the given ``image_id`` or + ``color_blending_id``. There is no cross-type fallback. + """ + provided = {"view_index": view_index, "image_id": image_id, "color_blending_id": color_blending_id} + provided_values = {k: v for k, v in provided.items() if v is not None} + + if len(provided_values) != 1: + raise ValueError( + "view_by_id requires exactly one of the keyword arguments " + "`view_index`, `image_id`, or `color_blending_id`; " + f"got {len(provided_values)} with values {provided_values!r}." + ) + + if view_index is not None: + try: + entry = self.get_value(f"imageViewConfigStore.imageListSummary[{view_index}]") + except (CartaActionFailed, CartaBadResponse) as e: + raise IndexError(f"view_index {view_index} is out of range for the views.") from e + return View.view_class(entry["type"])(self, entry["id"]) + + if image_id is not None: + try: + resolved_image_id = self.get_value(f"frameMap[{image_id}]", return_path="frameInfo.fileId") + except (CartaActionFailed, CartaBadResponse) as e: + raise RuntimeError(f"No image with image_id={image_id} is open.") from e + return Image(self, resolved_image_id) + + # color_blending_id is not None + try: + resolved_color_blending_id = self.get_value( + f"imageViewConfigStore.colorBlendingImageMap[{color_blending_id}]", return_path="id" + ) + except (CartaActionFailed, CartaBadResponse) as e: + raise RuntimeError(f"No color blending with color_blending_id={color_blending_id} is open.") from e + return ColorBlending(self, resolved_color_blending_id) + + def active_view(self): + """Return the currently active view. + + This is the image or color blending image that is currently active. + + Returns + ------- + :obj:`carta.image.Image` or :obj:`carta.color_blending.ColorBlending` + The currently active view. + + Raises + ------ + NotImplementedError + If the active view is of a type that is not yet wrapped on + the Python side. + """ + active = self.get_value("activeImage") + return View.view_class(active["type"])(self, active["store"]["id"]) + + # COLOR BLENDING + + @validate(NoneOr(IterableOf(InstanceOf(Image), min_size=1))) + def create_color_blending(self, images=None): + """Create a color blending from open images. + + There are two ways to choose the images for the new color blending: + + * If ``images`` is provided, it must contain one or more + :obj:`carta.image.Image` objects from this session. The first image + becomes the base layer and spatial reference. The remaining images + become secondary layers in the same order as the input. The result + contains exactly these images; other open images are left open but + are not included. + * If ``images`` is omitted, the current spatial reference and all + currently spatially matched images are used. + + This method creates a color blending from images that are already + open. It does not open files or close other open images. + + Parameters + ---------- + images : {0} + An iterable of open images to combine, in the desired layer order. + The first image is used as the base layer. By default, use the + current spatial reference and spatially matched images. + + Returns + ------- + :obj:`carta.color_blending.ColorBlending` + The new color blending object. + + Raises + ------ + CartaActionFailed + If no images are open, an image is no longer open, or the + frontend cannot create or update the color blending. + CartaValidationFailed + If ``images`` is empty, contains duplicates, or contains an image + from another session. + """ + if images is not None: + if any(i.session is not self for i in images): + raise CartaValidationFailed("images must belong to the current session.") + + image_ids = [i.image_id for i in images] + if len(set(image_ids)) != len(image_ids): + raise CartaValidationFailed("images must not contain duplicate images.") + + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + else: + image_count = self.get_value("frames.length") + if image_count <= 0: + raise CartaActionFailed("No images are open.") + + color_blending_id = self.call_action("imageViewConfigStore.createColorBlending", return_path="id") + cb = ColorBlending(self, color_blending_id) + + if images is not None: + # The frontend action includes every currently spatially matched + # image, so normalize the created blending to the requested + # sequence before applying the final colormap set. + for layer_index in range(cb.depth - 1, 0, -1): + cb.delete_layer(layer_index) + for image in images[1:]: + cb.add_layer(image) + + if cb.depth <= 3: + cb.set_colormap_set(ColormapSet.RGB) + else: + cb.set_colormap_set(ColormapSet.RAINBOW) + return cb def clear_spatial_reference(self): """Clear the spatial reference.""" @@ -610,8 +919,6 @@ def set_viewer_grid(self, rows, columns, grid_mode=GridMode.FIXED): def set_cursor(self, x, y): """Set the curson position. - TODO: this is a precursor to making z-profiles available, but currently the relevant functionality is not exposed by the frontend. There is also a frontend issue which is preventing the cursor from being updated correctly (it is updated only in the profiles). - Parameters ---------- x : {0} @@ -620,13 +927,13 @@ def set_cursor(self, x, y): The Y position. """ - self.active_frame().regions.call_action("updateCursorRegionPosition", Pt(x, y)) + self.call_action("activeFrame.setCursorPosition", Pt(x, y)) # SAVE IMAGE @validate(NoneOr(Color())) def rendered_view_url(self, background_color=None): - """Get a data URL of the rendered active image. + """Get a data URL of the rendered active view. Parameters ---------- @@ -647,7 +954,7 @@ def rendered_view_url(self, background_color=None): @validate(NoneOr(Color())) def rendered_view_data(self, background_color=None): - """Get the decoded data of the rendered active image. + """Get the decoded data of the rendered active view. Parameters ---------- @@ -666,7 +973,7 @@ def rendered_view_data(self, background_color=None): @validate(String(), NoneOr(Color())) def save_rendered_view(self, file_name, background_color=None): - """Save the decoded data of the rendered active image to a file. + """Save the decoded data of the rendered active view to a file. Parameters ---------- diff --git a/carta/util.py b/carta/util.py index 6df380c..5b4ebb4 100644 --- a/carta/util.py +++ b/carta/util.py @@ -130,7 +130,7 @@ def newfunc(self, *args): return self._cache[func.__name__] if newfunc.__doc__ is not None: - newfunc.__doc__ = re.sub(r"($|\n)", r" This value is transparently cached on the parent object.\1", newfunc.__doc__, 1) + newfunc.__doc__ = re.sub(r"($|\n)", r" This value is transparently cached on the parent object.\1", newfunc.__doc__, count=1) return newfunc diff --git a/carta/validation.py b/carta/validation.py index 9e68d15..c99bc30 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -910,7 +910,7 @@ def description(self): def validate(*vargs): """The function which returns the decorator used to validate method parameters. - It is assumed that the function to be decorated is an object method and the first parameter is ``self``; this parameter is therefore ignored by the decorator. The remaining positional parameters are validated in order using the provided descriptors. The descriptors are also combined pairwise with the parameter names in the signature of the original function to create a dictionary for validating keyword parameters. + It is assumed that the function to be decorated is an object method and the first parameter is ``self``; this parameter is therefore ignored by the decorator. The remaining positional and keyword-only parameters are validated in order using the provided descriptors. The descriptors are also combined pairwise with the parameter names in the signature of the original function to create a dictionary for validating keyword parameters. Functions with ``*args`` or ``**kwargs`` are not currently supported: use iterables and explicit keyword parameters instead. @@ -932,7 +932,9 @@ def validate(*vargs): """ def decorator(func): - kwvargs = {k: v for (k, v) in zip(inspect.getfullargspec(func).args[1:], vargs)} + spec = inspect.getfullargspec(func) + parameter_names = spec.args[1:] + spec.kwonlyargs + kwvargs = {k: v for (k, v) in zip(parameter_names, vargs)} STRIP_OBJ = re.compile(":obj:`(.*)`") STRIP_CODE = re.compile("``(.*)``") PRESERVE = re.compile("(:obj:`.+?`|``.+?``)") diff --git a/carta/view.py b/carta/view.py new file mode 100644 index 0000000..3362637 --- /dev/null +++ b/carta/view.py @@ -0,0 +1,75 @@ +"""Shared wrapper for heterogeneous views in a CARTA session. + +The class in this module should not be instantiated directly. It exists so +that :obj:`carta.image.Image` and :obj:`carta.color_blending.ColorBlending` +can share a common protocol without one having to import the other. +""" + + +from abc import ABC, abstractmethod + +from .constants import ImageType +from .validation import Constant, validate + + +class View(ABC): + """Base class for views (images and color blendings). + + This class is not intended to be instantiated directly. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this view. + """ + + CUSTOM_CLASS = {} + """Mapping of view types to their concrete wrapper classes.""" + + def __init_subclass__(cls, **kwargs): + """Register concrete view wrapper subclasses by view type.""" + super().__init_subclass__(**kwargs) + View.CUSTOM_CLASS[cls.VIEW_TYPE] = cls + + def __init__(self, session): + self.session = session + + @property + @abstractmethod + def _stable_id(self): + """The stable identifier of this view.""" + raise NotImplementedError # pragma: no cover + + @classmethod + @validate(Constant(ImageType)) + def view_class(cls, view_type): + """The wrapper class associated with a view type. + + Parameters + ---------- + view_type : {0} + The view type. + + Returns + ------- + class object + The concrete view wrapper class. + """ + view_type = ImageType(view_type) + view_class = cls.CUSTOM_CLASS.get(view_type) + if view_class is None: + raise NotImplementedError( + f"No View subclass is registered for view type {view_type!r}." + ) + return view_class + + @property + def view_index(self): + """The current index of this view in the session's views.""" + return self.session._find_view_index(self.VIEW_TYPE, self._stable_id) + + def make_active(self): + """Make this view the active view.""" + self.session.call_action( + "setActiveImageById", self.VIEW_TYPE, self._stable_id + ) diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index a82a4f7..f59396c 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -435,20 +435,14 @@ class ImageWCSConnector: ANY_IDS = NoneOr(IterableOf(Number.ID)) - def _images(self, image_ids=None): - """Internal helper function for fetching image objects.""" - if image_ids is None: - return self.session.image_list() - return [self.session.image_by_id(image_id) for image_id in image_ids] - def _get_image_wcs_properties(self, image_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" - images = self._images(image_ids) + images = self.session.images(image_ids) return tuple(attrgetter(property_path)(image.wcs) for image in images) def _call_image_wcs_functions(self, image_ids, function_path, *function_args): """Internal helper function for executing wcs functions on multiple images.""" - images = self._images(image_ids) + images = self.session.images(image_ids) for image in images: attrgetter(function_path)(image.wcs)(*function_args) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 54998fe..9ab1ea6 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -17,6 +17,14 @@ carta.browser module :undoc-members: :show-inheritance: +carta.color_blending module +--------------------------- + +.. automodule:: carta.color_blending + :members: + :undoc-members: + :show-inheritance: + carta.constants module ---------------------- @@ -41,6 +49,14 @@ carta.image module :undoc-members: :show-inheritance: +carta.view module +----------------- + +.. automodule:: carta.view + :members: + :undoc-members: + :show-inheritance: + carta.metadata module --------------------- @@ -136,4 +152,3 @@ carta.wcs_overlay module :members: :undoc-members: :show-inheritance: - diff --git a/docs/source/images/image_list.jpg b/docs/source/images/image_list.jpg new file mode 100644 index 0000000..4a12a2b Binary files /dev/null and b/docs/source/images/image_list.jpg differ diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 23afaf8..253e242 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -175,9 +175,57 @@ Helper methods on the session object open images in the frontend and return imag .. code-block:: python # Open or append images - img1 = session.open_image("data/hdf5/first_file.hdf5") - img2 = session.open_image("data/fits/second_file.fits", append=True) - + img0 = session.open_image("data/hdf5/first_file.hdf5") + img1 = session.open_image("data/fits/second_file.fits", append=True) + img2 = session.open_image("data/fits/third_file.fits", append=True) + + # Open multiple images in one call + img0, img1, img2 = session.open_images([ + "data/hdf5/first_file.hdf5", + "data/fits/second_file.fits", + "data/fits/third_file.fits", + ]) + +Inspecting the list of open images +---------------------------------- + +The session's views are heterogeneous: they may contain both ordinary images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.color_blending.ColorBlending`). Their order matches the views panel shown in the frontend, as illustrated below. + +.. figure:: images/image_list.jpg + :alt: CARTA frontend views panel showing images and a color blending entry. + :align: center + + The frontend views panel. Each row corresponds to an item returned by :obj:`carta.session.Session.views`, and its position in the list is the item's ``view_index``. + +Use :meth:`carta.session.Session.images` and +:meth:`carta.session.Session.color_blendings` to retrieve the two concrete +view types directly, optionally filtering by their stable IDs. + +.. code-block:: python + + # All open views, in display order + items = session.views() + + # Select views by their current indices, preserving the requested order + selected = session.views(view_indices=[1, 0]) + + # Get images or color blending images directly + images = session.images() + color_blendings = session.color_blendings() + + # Every view exposes its current view index + print(img0.view_index) + + # Retrieve a specific view by view index + img = session.view_by_id(view_index=0) + cb = session.view_by_id(view_index=1) + + # Filter by stable IDs when needed + images = session.images(image_ids=[img0.image_id, img1.image_id]) + color_blendings = session.color_blendings( + color_blending_ids=[cb.color_blending_id] + ) + Changing image properties ------------------------- @@ -186,7 +234,7 @@ Properties specific to individual images can be accessed through image objects: .. code-block:: python import numpy as np - from carta.constants import Colormap, Scaling, Polarization + from carta.constants import Colormap, ColormapSet, Scaling, Polarization # change the channel and polarization img.set_channel(10) @@ -196,7 +244,7 @@ Properties specific to individual images can be accessed through image objects: # pan and zoom y, x = img.shape[-2:] img.set_center(x/2, y/2) - img.set_zoom(4) + img.set_zoom_level(4) # change colormap img.raster.set_colormap(Colormap.VIRIDIS) @@ -229,7 +277,131 @@ Properties which affect the whole session can be set through the session object: session.wcs.global_.set_color(PaletteColor.RED) session.wcs.ticks.set_color(PaletteColor.VIOLET) session.wcs.title.show() - + +Making color blended image +-------------------------- + +The session object provides two convenience methods which create a color blending image and apply a sensible default colormap set (``RGB`` for three layers or fewer, ``RAINBOW`` for more): + +.. code-block:: python + + files = [ + "data/hdf5/first_file.hdf5", + "data/fits/second_file.fits", + "data/fits/third_file.fits", + ] + + # Open the files and combine them into a new color blending image. + # This closes any currently open images, makes the first opened + # image the spatial reference, and spatially matches the remaining + # opened images to it. + cb = session.open_as_color_blending(files) + + # Create a new color blending from the current spatial reference + # and its currently spatially matched images. + # Set the desired base image as the current spatial reference and + # enable spatial matching for the other layers first. + session.clear_spatial_reference() + img0.make_spatial_reference() + img1.set_spatial_matching(True) + img2.set_spatial_matching(True) + cb = session.create_color_blending() + + # Or select already-open images directly in the requested layer order. + cb = session.create_color_blending( + images=[img0, img1, img2] + ) + +.. note:: + ``session.open_as_color_blending(files)`` always closes any currently + open images before opening ``files``. It then makes the first opened + image the spatial reference and spatially matches the remaining + opened images to it before creating the color blending. + +Manipulate properties of the color blending object and the underlying layers: + +.. code-block:: python + + # Get layer objects + layer1, layer2, layer3 = cb.layers() + + # Set colormap for the images in individual layers + layer1.set_colormap(Colormap.REDS) + layer2.set_colormap(Colormap.GREENS) + layer3.set_colormap(Colormap.BLUES) + + # Inspect the colormap, inversion, and alpha of an individual layer + print(layer1.colormap, layer1.inverted, layer1.alpha) + + # Or apply an existing colormap set + cb.set_colormap_set(ColormapSet.RGB) + + # Print the current alpha values of all layers + print(cb.alphas) + + # Set alpha for individual layers + layer1.set_alpha(0.7) + layer2.set_alpha(0.8) + layer3.set_alpha(0.9) + + # Or set alpha for all layers at once + cb.set_alphas([0.7, 0.8, 0.9]) + + # Replace the image in a layer + # For layer1, this also makes the new image the spatial reference. + # layer1.set_image(new_image) + + # Remove the last layer (index = 2) + cb.delete_layer(2) + + # Add the removed image back as a new layer + # The layer to be added cannot be one of the current layers + cb.add_layer(img2) + + # Layer objects can delete themselves from the color blending + layer1, layer2, layer3 = cb.layers() + layer3.delete() + + # Deleting the base layer promotes the next layer to the spatial + # reference. The old base remains spatially matched but is removed + # from the color blending layers. + layer1.delete() + + # Append the old base as a new color blending layer if desired. + cb.add_layer(img0) + + # Set center + cb.set_center(100, 100) + + # Set zoom level + cb.set_zoom_level(2) + + # Get the current view index of the color blending image + print(cb.view_index) + + # Set the color blending object as the active view + cb.make_active() + + # Set contour visibility + # This will hide the contours (if any) + cb.set_contour_visible(False) + + # Close the color blending object + cb.close() + +.. note:: + Layer indices are zero-based and refer to the current layer list. The + base layer (index = 0) can be deleted: if other layers remain, the next + layer becomes the new spatial reference; if it is the only layer, the + color blending object is closed. The old base remains spatially matched + to the new reference but is no longer part of the color blending until it + is appended as a new color blending layer with + :meth:`carta.color_blending.ColorBlending.add_layer`. + + An image can be replaced through :meth:`carta.color_blending.Layer.set_image`. + Replacing the base layer first enables spatial matching for the new image + and then makes it the spatial reference. + Saving or displaying an image ----------------------------- @@ -258,9 +430,9 @@ Closing images .. code-block:: python - # Close all images open in the session - for img in session.image_list(): - img.close() + # Close all views open in the session + for item in session.views(): + item.close() Closing the session ------------------- diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py new file mode 100644 index 0000000..69ab537 --- /dev/null +++ b/tests/test_color_blending.py @@ -0,0 +1,727 @@ +import pytest + +from carta.color_blending import ColorBlending, Layer +from carta.constants import Colormap as CM +from carta.constants import ColormapSet as CMS +from carta.constants import ImageType +from carta.constants import SpatialAxis as SA +from carta.image import Image +from carta.util import CartaActionFailed, CartaValidationFailed, Macro + +# FIXTURES + + +@pytest.fixture +def color_blending(session): + return ColorBlending(session, 0) + + +@pytest.fixture +def layer(color_blending): + return Layer(color_blending, 1) + + +@pytest.fixture +def cb_get_value(color_blending, mock_get_value): + return mock_get_value(color_blending) + + +@pytest.fixture +def cb_call_action(color_blending, mock_call_action): + return mock_call_action(color_blending) + + +@pytest.fixture +def layer_get_value(layer, mock_get_value): + return mock_get_value(layer) + + +@pytest.fixture +def layer_call_action(layer, mock_call_action): + return mock_call_action(layer) + + +@pytest.fixture +def session_call_action(session, mock_call_action): + return mock_call_action(session) + + +@pytest.fixture +def session_get_value(session, mock_get_value): + return mock_get_value(session) + + +@pytest.fixture +def cb_property(mock_property): + return mock_property("carta.color_blending.ColorBlending") + + +@pytest.fixture +def layer_property(mock_property): + return mock_property("carta.color_blending.Layer") + + +# TESTS — Layer + + +def test_layer_from_list(color_blending): + layers = Layer.from_list(color_blending, [5, 6, 7]) + assert [ly.layer_id for ly in layers] == [5, 6, 7] + assert all(ly.color_blending is color_blending for ly in layers) + + +def test_layer_repr_healthy(session, color_blending, layer_property, mocker): + find = mocker.patch.object(session, "_find_view_index", return_value=2) + layer_property("image_id", 42) + layer_property("file_name", "layer1.fits") + layer_property("colormap", "viridis") + layer_property("inverted", False) + layer_property("alpha", 0.5) + r = repr(Layer(color_blending, 3)) + assert r == ( + "Layer(view_index=2, color_blending_id=0, layer_id=3, " + "file_name='layer1.fits', colormap='viridis', inverted=False, " + "alpha=0.5)" + ) + find.assert_called_once_with(ImageType.FRAME, 42) + + +def test_layer_repr_closed_when_image_not_in_views( + session, color_blending, layer_property, mocker +): + layer_property("image_id", 42) + mocker.patch.object( + session, + "_find_view_index", + side_effect=RuntimeError("not in views"), + ) + r = repr(Layer(color_blending, 3)) + assert r == ( + "[Closed] Layer(view_index=None, color_blending_id=0, " + "layer_id=3)" + ) + + +def test_layer_repr_closed_when_image_is_gone(session, color_blending, mocker): + mocker.patch( + "carta.color_blending.Layer.image_id", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("frame is gone"), + ) + r = repr(Layer(color_blending, 3)) + assert r == ( + "[Closed] Layer(view_index=None, color_blending_id=0, " + "layer_id=3)" + ) + + +def test_layer_repr_closed_when_file_name_read_fails( + session, color_blending, layer_property, mocker +): + mocker.patch.object(session, "_find_view_index", return_value=2) + layer_property("image_id", 42) + mocker.patch( + "carta.color_blending.Layer.file_name", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("file_name read failed"), + ) + r = repr(Layer(color_blending, 3)) + assert r == ( + "[Closed] Layer(view_index=2, color_blending_id=0, " + "layer_id=3)" + ) + + +def test_layer_file_name_property(layer, layer_get_value): + layer.file_name + layer_get_value.assert_called_with("frameInfo.fileInfo.name") + + +def test_layer_image_id_property(layer, layer_get_value): + layer.image_id + layer_get_value.assert_called_with("frameInfo.fileId") + + +def test_layer_colormap_property(layer, layer_get_value): + layer_get_value.return_value = "viridis" + + assert layer.colormap == "viridis" + layer_get_value.assert_called_once_with("renderConfig.colorMap") + + +def test_layer_inverted_property(layer, layer_get_value): + layer_get_value.return_value = True + + assert layer.inverted is True + layer_get_value.assert_called_once_with("renderConfig.isInverted") + + +def test_layer_alpha_property(layer, cb_get_value): + cb_get_value.return_value = 0.5 + + assert layer.alpha == 0.5 + cb_get_value.assert_called_once_with("alpha[1]") + + +def test_layer_delete(layer, mocker): + delete_layer = mocker.patch.object(layer.color_blending, "delete_layer") + + layer.delete() + + delete_layer.assert_called_once_with(layer.layer_id) + + +def test_layer_set_image(layer, image, mocker): + set_layer_image = mocker.patch.object( + layer.color_blending, "set_layer_image" + ) + + layer.set_image(image) + + set_layer_image.assert_called_once_with(layer.layer_id, image) + + +def test_layer_set_image_rejects_invalid_image(layer, mocker): + set_layer_image = mocker.patch.object( + layer.color_blending, "set_layer_image" + ) + + with pytest.raises(CartaValidationFailed): + layer.set_image(object()) + + set_layer_image.assert_not_called() + + +def test_layer_view_index(session, color_blending, layer_property, mocker): + find = mocker.patch.object(session, "_find_view_index", return_value=7) + layer_property("image_id", 42) + assert Layer(color_blending, 3).view_index == 7 + find.assert_called_once_with(ImageType.FRAME, 42) + + +def test_layer_view_index_raises_when_image_not_in_views( + session, color_blending, layer_property, mocker +): + layer_property("image_id", 42) + mocker.patch.object( + session, + "_find_view_index", + side_effect=RuntimeError("not in views"), + ) + with pytest.raises(RuntimeError): + Layer(color_blending, 3).view_index + + +@pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0]) +def test_layer_set_alpha_valid(color_blending, alpha, cb_call_action): + Layer(color_blending, 2).set_alpha(alpha) + cb_call_action.assert_called_with("setAlpha", 2, alpha) + + +@pytest.mark.parametrize("alpha", [-0.1, 1.1]) +def test_layer_set_alpha_invalid(color_blending, alpha): + with pytest.raises(CartaValidationFailed): + Layer(color_blending, 2).set_alpha(alpha) + + +@pytest.mark.parametrize("invert", [True, False]) +def test_layer_set_colormap(layer, layer_call_action, invert): + layer.set_colormap(CM.VIRIDIS, invert) + layer_call_action.assert_any_call("renderConfig.setColorMap", CM.VIRIDIS) + layer_call_action.assert_any_call("renderConfig.setInverted", invert) + + +def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): + with pytest.raises(CartaValidationFailed): + layer.set_colormap("not-a-colormap") + + layer_call_action.assert_not_called() + + +# TESTS — ColorBlending basics + + +def test_color_blending_init(session): + color_blending = ColorBlending(session, 3) + assert color_blending.color_blending_id == 3 + expected = "imageViewConfigStore.colorBlendingImageMap[3]" + assert color_blending._base_path == expected + assert color_blending._frame == Macro( + "", "imageViewConfigStore.colorBlendingImageMap[3]" + ) + + +def test_color_blending_repr_healthy(session, color_blending, cb_property, mocker): + mocker.patch.object(session, "call_action", return_value=2) + cb_property("file_name", "Color Blending 1") + r = repr(color_blending) + assert r == ( + "ColorBlending(view_index=2, color_blending_id=0, " + "file_name='Color Blending 1')" + ) + + +def test_color_blending_repr_closed_when_not_in_views( + session, color_blending, mocker +): + mocker.patch.object( + session, + "call_action", + side_effect=RuntimeError("not in views"), + ) + r = repr(color_blending) + assert r == ( + "[Closed] ColorBlending(view_index=None, color_blending_id=0)" + ) + + +def test_color_blending_repr_closed_when_backing_entry_is_gone( + session, color_blending, mocker +): + mocker.patch.object(session, "call_action", return_value=2) + mocker.patch( + "carta.color_blending.ColorBlending.file_name", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("color blending is gone"), + ) + r = repr(color_blending) + assert r == ( + "[Closed] ColorBlending(view_index=2, color_blending_id=0)" + ) + + +def test_color_blending_file_name(color_blending, cb_get_value): + color_blending.file_name + cb_get_value.assert_called_with("filename") + + +def test_color_blending_view_index( + session, color_blending, session_call_action +): + session_call_action.return_value = 2 + assert color_blending.view_index == 2 + session_call_action.assert_called_once_with( + "imageViewConfigStore.getImageListIndex", + ImageType.COLOR_BLENDING, + 0, + response_expected=True, + ) + + +def test_color_blending_view_index_raises_when_missing( + session, color_blending, session_call_action +): + session_call_action.return_value = -1 + with pytest.raises(RuntimeError): + color_blending.view_index + + +def test_color_blending_alpha(color_blending, cb_get_value): + color_blending.alphas + cb_get_value.assert_called_with("alpha") + + +def test_color_blending_base_frame(color_blending, cb_get_value): + cb_get_value.return_value = 42 + base_frame = color_blending._base_frame + + cb_get_value.assert_called_once_with("frames[0].id") + assert isinstance(base_frame, Image) + assert base_frame.session is color_blending.session + assert base_frame.image_id == 42 + + +def test_color_blending_make_active(session, color_blending, session_call_action): + # make_active must be driven by color_blending_id via setActiveImageById. + # It must not depend on view_index (which is volatile). + color_blending.make_active() + session_call_action.assert_called_with( + "setActiveImageById", ImageType.COLOR_BLENDING, 0 + ) + + +def test_color_blending_make_active_does_not_read_view_index( + session, color_blending, session_call_action, session_get_value +): + color_blending.make_active() + for call in session_get_value.call_args_list: + assert call.args != ("imageViewConfigStore.imageListSummary",) + + +def test_color_blending_layers_derived(session, mocker): + cb = ColorBlending(session, 3) + + # Simulate two layers from the frontend's computed frames array length. + gv = mocker.patch.object(cb, "get_value") + gv.return_value = 2 + + layers = cb.layers() + assert [ly.layer_id for ly in layers] == [0, 1] + gv.assert_called_once_with("frames.length") + + +def test_color_blending_layers_accepts_ids_in_order_with_duplicates( + color_blending, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + + layers = color_blending.layers([2, 0, 2]) + + assert [layer.layer_id for layer in layers] == [2, 0, 2] + + +@pytest.mark.parametrize("layer_ids", [[-1], [3], [1.5], ["1"]]) +def test_color_blending_layers_rejects_invalid_ids( + color_blending, mocker, layer_ids +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + from_list = mocker.patch.object(Layer, "from_list") + + with pytest.raises(CartaValidationFailed): + color_blending.layers(layer_ids) + + from_list.assert_not_called() + + +def test_color_blending_add_layer(color_blending, cb_call_action, image): + color_blending.add_layer(image) + cb_call_action.assert_called_with("addSelectedFrame", image._frame) + + +def test_color_blending_add_layer_rejects_invalid_image( + color_blending, cb_call_action +): + with pytest.raises(CartaValidationFailed): + color_blending.add_layer(object()) + + cb_call_action.assert_not_called() + + +@pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) +def test_color_blending_delete_layer( + color_blending, cb_call_action, idx, expected_param, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=4, + ) + color_blending.delete_layer(idx) + cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) + + +def test_color_blending_delete_base_layer_promotes_next_layer( + color_blending, cb_call_action, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + layers = [Layer(color_blending, 0), Layer(color_blending, 1)] + mocker.patch.object(color_blending, "layers", return_value=layers) + mocker.patch.object( + Layer, "image_id", new_callable=mocker.PropertyMock, return_value=42 + ) + image = mocker.patch("carta.color_blending.Image", autospec=True) + + color_blending.delete_layer(0) + + cb_call_action.assert_not_called() + image.assert_called_once_with(color_blending.session, 42) + image.return_value.make_spatial_reference.assert_called_once_with() + + +def test_color_blending_delete_only_base_layer_closes_color_blending( + color_blending, cb_call_action, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=1, + ) + mocker.patch.object( + color_blending, "layers", return_value=[Layer(color_blending, 0)] + ) + close = mocker.patch.object(color_blending, "close") + + color_blending.delete_layer(0) + + close.assert_called_once_with() + cb_call_action.assert_not_called() + + +@pytest.mark.parametrize("idx", [-1, 2]) +def test_color_blending_delete_layer_rejects_out_of_range( + color_blending, cb_call_action, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.delete_layer(idx) + + cb_call_action.assert_not_called() + + +@pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) +def test_color_blending_set_layer_image( + color_blending, cb_call_action, image, idx, expected_param, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=6, + ) + mocker.patch.object(color_blending, "layers", return_value=[]) + color_blending.set_layer_image(idx, image) + cb_call_action.assert_called_with( + "setSelectedFrame", expected_param, image._frame + ) + + +@pytest.mark.parametrize("idx", [-1, 2]) +def test_color_blending_set_layer_image_rejects_out_of_range( + color_blending, cb_call_action, image, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.set_layer_image(idx, image) + + cb_call_action.assert_not_called() + + +@pytest.mark.parametrize("idx", [0, 2]) +def test_color_blending_set_layer_image_rejects_existing_image( + color_blending, cb_call_action, image, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + mocker.patch.object(color_blending, "layers", return_value=[Layer(color_blending, 1)]) + mocker.patch.object( + Layer, + "image_id", + new_callable=mocker.PropertyMock, + return_value=image.image_id, + ) + set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") + make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") + + with pytest.raises(CartaValidationFailed, match="already"): + color_blending.set_layer_image(idx, image) + + cb_call_action.assert_not_called() + set_spatial_matching.assert_not_called() + make_spatial_reference.assert_not_called() + + +def test_color_blending_set_base_layer_image( + color_blending, cb_call_action, image, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=1, + ) + mocker.patch.object(color_blending, "layers", return_value=[]) + set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") + make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") + + color_blending.set_layer_image(0, image) + + set_spatial_matching.assert_called_once_with(True) + make_spatial_reference.assert_called_once_with() + cb_call_action.assert_not_called() + + +def test_color_blending_set_center(color_blending, mocker): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.color_blending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + color_blending.set_center(1, 2) + base_frame.set_center.assert_called_once_with(1, 2) + + +@pytest.mark.parametrize("size,axis", [(123, SA.X), ("123arcsec", SA.Y)]) +def test_color_blending_zoom_to_size(color_blending, mocker, size, axis): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.color_blending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + color_blending.zoom_to_size(size, axis) + base_frame.zoom_to_size.assert_called_once_with(size, axis) + + +@pytest.mark.parametrize("size,axis", [("123px", SA.X), (123, "z")]) +def test_color_blending_zoom_to_size_invalid(color_blending, mocker, size, axis): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.color_blending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.zoom_to_size(size, axis) + + base_frame.zoom_to_size.assert_not_called() + + +@pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) +def test_color_blending_set_zoom_level(color_blending, mocker, zoom, absolute): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.color_blending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + color_blending.set_zoom_level(zoom, absolute) + base_frame.set_zoom_level.assert_called_once_with(zoom, absolute) + + +def test_color_blending_set_colormap_set(color_blending, cb_call_action): + color_blending.set_colormap_set(CMS.RAINBOW) + cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) + + +def test_color_blending_set_alphas_valid(color_blending, mocker): + ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + mocker.patch.object(ColorBlending, "layers", return_value=[ly1, ly2]) + + color_blending.set_alphas([0.2, 0.8]) + ly1.set_alpha.assert_called_with(0.2) + ly2.set_alpha.assert_called_with(0.8) + + +@pytest.mark.parametrize("vals", [[-0.1, 0.5], [1.2], [0.1, 2.0, 0.3]]) +def test_color_blending_set_alphas_invalid(color_blending, vals, mocker): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.set_alphas(vals) + + +@pytest.mark.parametrize("vals", [[0.5], [0.1, 0.2, 0.3]]) +def test_color_blending_set_alphas_length_mismatch(color_blending, mocker, vals): + ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + mocker.patch.object(ColorBlending, "layers", return_value=[ly1, ly2]) + + with pytest.raises(CartaValidationFailed): + color_blending.set_alphas(vals) + + +@pytest.mark.parametrize( + "getter,method,action,state", + [ + ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), + ( + "contourVisible", + "set_contour_visible", + "toggleContourVisible", + True, + ), + ( + "vectorOverlayVisible", + "set_vector_overlay_visible", + "toggleVectorOverlayVisible", + False, + ), + ], +) +def test_color_blending_toggle_visibility_when_needed( + color_blending, cb_get_value, cb_call_action, getter, method, action, state +): + # Current state opposite to desired -> should toggle + cb_get_value.side_effect = [not state] + getattr(color_blending, method)(state) + cb_call_action.assert_called_with(action) + + +@pytest.mark.parametrize( + "getter,method,action,state", + [ + ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), + ( + "contourVisible", + "set_contour_visible", + "toggleContourVisible", + False, + ), + ( + "vectorOverlayVisible", + "set_vector_overlay_visible", + "toggleVectorOverlayVisible", + True, + ), + ], +) +def test_color_blending_toggle_visibility_noop( + color_blending, cb_get_value, cb_call_action, getter, method, action, state +): + # Current state equals desired -> no toggle + cb_get_value.side_effect = [state] + getattr(color_blending, method)(state) + cb_call_action.assert_not_called() + + +def test_color_blending_close(session, color_blending, session_call_action): + color_blending.close() + session_call_action.assert_called_with( + "imageViewConfigStore.removeColorBlending", color_blending._frame + ) diff --git a/tests/test_image.py b/tests/test_image.py index 9a1c275..1627c3b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,8 +1,9 @@ import pytest from carta.image import Image -from carta.util import CartaValidationFailed, Point as Pt -from carta.constants import NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU +from carta.view import View +from carta.util import CartaActionFailed, CartaValidationFailed, Point as Pt +from carta.constants import ImageType, NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU # FIXTURES @@ -110,7 +111,126 @@ def test_simple_properties(image, property_name, expected_path, get_value): def test_make_active(image, session_call_action): image.make_active() - session_call_action.assert_called_with("setActiveFrameById", 0) + session_call_action.assert_called_with( + "setActiveImageById", ImageType.FRAME, 0 + ) + + +def test_make_spatial_reference_disables_color_blending_alert( + image, session_call_action +): + image.make_spatial_reference() + session_call_action.assert_called_once_with( + "setSpatialReference", image._frame, False + ) + + +def test_view_is_abstract(session): + with pytest.raises(TypeError, match=r"abstract method.*_stable_id"): + View(session) + + +def test_view_make_active_uses_subclass_ids(session, session_call_action): + # Verify the shared View.make_active dispatches setActiveImageById + # with Image.VIEW_TYPE and its stable ID exactly once. + Image(session, 42).make_active() + session_call_action.assert_called_once_with( + "setActiveImageById", ImageType.FRAME, 42 + ) + + +def test_view_class_resolves_registered_subclasses(): + from carta.color_blending import ColorBlending + + assert View.view_class(ImageType.FRAME) is Image + assert View.view_class(ImageType.COLOR_BLENDING) is ColorBlending + + +def test_view_index_uses_subclass_ids(session, mocker): + find = mocker.patch.object(session, "_find_view_index", return_value=5) + from carta.color_blending import ColorBlending + + assert ColorBlending(session, 42).view_index == 5 + find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) + + +def test_view_subclass_requires_view_type(): + with pytest.raises( + AttributeError, match="has no attribute 'VIEW_TYPE'" + ): + class Dummy(View): + pass + + +def test_view_index_uses_find_view_index(session, mocker, image): + find = mocker.patch.object( + session, "_find_view_index", return_value=3 + ) + # Image with image_id=0 at view index 3. + assert image.view_index == 3 + find.assert_called_once_with(ImageType.FRAME, 0) + + +def test_view_index_raises_when_missing(session, mocker): + mocker.patch.object( + session, "_find_view_index", side_effect=RuntimeError + ) + img = Image(session, 3) + with pytest.raises(RuntimeError): + img.view_index + + +def test_image_repr_cached_name_resolves_only_view_index(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) + get_value = mocker.patch.object(image, "get_value") + image._cache = {"file_name": "cube.fits"} + r = repr(image) + assert r == "Image(view_index=3, file_name='cube.fits', image_id=0)" + get_value.assert_not_called() + + +def test_image_repr_resolves_view_index_and_file_name(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) + mocker.patch.object(image, "get_value", return_value="cube.fits") + r = repr(image) + assert r == "Image(view_index=3, file_name='cube.fits', image_id=0)" + + +def test_image_repr_closed_when_view_index_missing(session, image, mocker): + mocker.patch.object( + session, + "_find_view_index", + side_effect=RuntimeError("not in views"), + ) + r = repr(image) + assert r == "[Closed] Image(view_index=None, image_id=0)" + + +def test_image_repr_closed_shows_cached_file_name(session, image, mocker): + # When the view-index lookup fails but file_name was previously + # cached, the closed repr still surfaces the cached name without + # triggering any fresh round-trip. + mocker.patch.object( + session, + "_find_view_index", + side_effect=RuntimeError("not in views"), + ) + get_value = mocker.patch.object(image, "get_value") + image._cache = {"file_name": "cube.fits"} + r = repr(image) + assert r == "[Closed] Image(view_index=None, file_name='cube.fits', image_id=0)" + get_value.assert_not_called() + + +def test_image_repr_closed_when_image_is_gone(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) + mocker.patch.object( + image, + "get_value", + side_effect=CartaActionFailed("imageMap entry is missing"), + ) + r = repr(image) + assert r == "[Closed] Image(view_index=3, image_id=0)" @pytest.mark.parametrize("channel", [0, 10, 19]) diff --git a/tests/test_session.py b/tests/test_session.py index cf79849..5ef1116 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,8 +1,12 @@ +from unittest.mock import call + import pytest from carta.image import Image -from carta.util import Macro -from carta.constants import ComplexComponent as CC, Polarization as Pol +from carta.color_blending import ColorBlending +from carta.session import Session +from carta.util import CartaActionFailed, CartaBadResponse, CartaValidationFailed, Macro, Point as Pt +from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -32,6 +36,50 @@ def method(session, mock_method): def test_subobjects(session, name, classname): assert getattr(session, name).__class__.__name__ == classname + +def test_carta_version_property(session, get_value): + get_value.return_value = "6.0.0" + + assert session.carta_version == "6.0.0" + assert session.carta_version == "6.0.0" + + get_value.assert_called_once_with("frontendVersion") + + +def test_session_repr_includes_carta_version(session, get_value, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + get_value.return_value = "6.0.0" + + assert ( + repr(session) + == "Session(session_id=0, uri='http://localhost:3000', carta_version='6.0.0')" + ) + + +def test_session_repr_uses_cached_carta_version_without_lookup( + session, mocker +): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + session._cache = {"carta_version": "6.0.0"} + get_value = mocker.patch.object(session, "get_value") + + assert ( + repr(session) + == "Session(session_id=0, uri='http://localhost:3000', carta_version='6.0.0')" + ) + get_value.assert_not_called() + + +def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch.object( + session, + "get_value", + side_effect=CartaActionFailed("frontendVersion unavailable"), + ) + + assert repr(session) == "Session(session_id=0, uri='http://localhost:3000')" + # PATHS @@ -69,6 +117,589 @@ def test_cd(session, method, call_action): session.cd("original/path") call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") + +# VIEWS / IMAGES / COLOR-BLENDING HELPERS + + +def test_views_heterogeneous(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.FRAME, "id": 20}, + ] + + views = session.views() + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + assert len(views) == 3 + assert isinstance(views[0], Image) and views[0].image_id == 10 + assert isinstance(views[1], ColorBlending) and views[1].color_blending_id == 3 + assert isinstance(views[2], Image) and views[2].image_id == 20 + + +def test_views_skips_unsupported_view_type(session, get_value, capsys): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.PV_PREVIEW, "id": 11}, + {"type": ImageType.FRAME, "id": 20}, + ] + + views = session.views() + + assert [view.image_id for view in views] == [10, 20] + assert capsys.readouterr().out == "Skipping unsupported PV_PREVIEW view at index 1.\n" + + +def test_views_empty(session, get_value): + get_value.return_value = [] + assert session.views() == [] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_views_uses_one_summary_for_explicit_indices(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.FRAME, "id": 20}, + ] + + views = session.views(view_indices=[2, 0, 2]) + + assert [ + (type(view), view.image_id if isinstance(view, Image) else view.color_blending_id) + for view in views + ] == [(Image, 20), (Image, 10), (Image, 20)] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_views_explicit_indices_list_all_out_of_range(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + ] + + with pytest.raises(IndexError) as exc_info: + session.views(view_indices=[2, 0, 1]) + + assert str(exc_info.value) == "No views with indices [2, 1] are open." + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_views_explicit_indices_raise_for_unsupported_type(session, get_value): + get_value.return_value = [ + {"type": ImageType.PV_PREVIEW, "id": -2} + ] + + with pytest.raises(NotImplementedError): + session.views(view_indices=[0]) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +@pytest.mark.parametrize("view_indices", [[-1], [1.5], ["1"]]) +def test_views_rejects_invalid_indices(session, get_value, view_indices): + with pytest.raises(CartaValidationFailed): + session.views(view_indices=view_indices) + + get_value.assert_not_called() + + +def test_images_uses_frontend_frame_names(session, get_value): + get_value.return_value = [ + {"value": 10, "label": "Image 10"}, + {"value": 20, "label": "Image 20"}, + ] + + images = session.images() + + assert [image.image_id for image in images] == [10, 20] + get_value.assert_called_once_with( + "frameNames" + ) + + +def test_images_uses_frame_names_for_explicit_ids(session, get_value): + get_value.return_value = [ + {"value": 10, "label": "Image 10"}, + {"value": 20, "label": "Image 20"}, + ] + + images = session.images(image_ids=[20, 10, 20]) + + assert [image.image_id for image in images] == [20, 10, 20] + get_value.assert_called_once_with("frameNames") + + +def test_images_list_all_closed_explicit_ids(session, get_value): + get_value.return_value = [{"value": 10, "label": "Image 10"}] + + with pytest.raises(RuntimeError) as exc_info: + session.images(image_ids=[30, 10, 20]) + + assert str(exc_info.value) == "No images with image_ids [30, 20] are open." + + get_value.assert_called_once_with("frameNames") + + +def test_color_blendings_uses_frontend_image_list_summary( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ] + + color_blendings = session.color_blendings() + + assert [ + color_blending.color_blending_id + for color_blending in color_blendings + ] == [3, 7] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_color_blendings_uses_image_list_summary_for_explicit_ids( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ] + + color_blendings = session.color_blendings([7, 3, 7]) + + assert [ + color_blending.color_blending_id + for color_blending in color_blendings + ] == [7, 3, 7] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_color_blendings_list_all_closed_explicit_ids(session, get_value): + get_value.return_value = [ + {"type": ImageType.COLOR_BLENDING, "id": 3} + ] + + with pytest.raises(RuntimeError) as exc_info: + session.color_blendings([7, 3, 5]) + + assert ( + str(exc_info.value) + == "No color blendings with color_blending_ids [7, 5] are open." + ) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_find_view_index_single_round_trip(session, call_action): + call_action.side_effect = [2, 1] + + assert session._find_view_index(ImageType.FRAME, 3) == 2 + assert session._find_view_index(ImageType.COLOR_BLENDING, 7) == 1 + assert call_action.call_args_list == [ + call( + "imageViewConfigStore.getImageListIndex", + ImageType.FRAME, + 3, + response_expected=True, + ), + call( + "imageViewConfigStore.getImageListIndex", + ImageType.COLOR_BLENDING, + 7, + response_expected=True, + ), + ] + + +def test_find_view_index_raises_when_missing(session, call_action): + call_action.return_value = -1 + with pytest.raises(RuntimeError): + session._find_view_index(ImageType.FRAME, 99) + + +# session.view_by_id + + +def test_view_by_id_requires_exactly_one_keyword(session, get_value): + # Zero keywords -> ValueError with all three names listed. + with pytest.raises(ValueError) as e: + session.view_by_id() + for name in ("view_index", "image_id", "color_blending_id"): + assert name in str(e.value) + assert "got 0 with values {}" in str(e.value) + # Multiple keywords -> ValueError. + with pytest.raises(ValueError) as e: + session.view_by_id(image_id=1, color_blending_id=2) + assert "'image_id': 1" in str(e.value) + assert "'color_blending_id': 2" in str(e.value) + + +def test_view_by_id_rejects_positional(session): + with pytest.raises(TypeError): + session.view_by_id(0) + + +@pytest.mark.parametrize("keyword", [ + "view_index", + "image_id", + "color_blending_id", +]) +@pytest.mark.parametrize("value", [-1, 1.5, "1"]) +def test_view_by_id_rejects_invalid_identifier( + session, get_value, keyword, value +): + with pytest.raises(CartaValidationFailed): + session.view_by_id(**{keyword: value}) + + get_value.assert_not_called() + + +@pytest.mark.parametrize( + "entry,expected_type,expected_id", + [ + ({"type": ImageType.FRAME, "id": 10}, Image, 10), + ( + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ColorBlending, + 7, + ), + ], +) +def test_view_by_id_by_view_index( + session, get_value, entry, expected_type, expected_id +): + get_value.return_value = entry + + img = session.view_by_id(view_index=0) + assert isinstance(img, expected_type) + assert ( + img.image_id if expected_type is Image else img.color_blending_id + ) == expected_id + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary[0]" + ) + + +def test_view_by_id_by_view_index_out_of_range(session, get_value): + get_value.side_effect = CartaBadResponse("undefined") + + with pytest.raises(IndexError): + session.view_by_id(view_index=99) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary[99]" + ) + + +def test_view_by_id_by_view_index_raises_on_unsupported_type( + session, get_value +): + get_value.return_value = {"type": ImageType.PV_PREVIEW, "id": -2} + + with pytest.raises(NotImplementedError): + session.view_by_id(view_index=0) + + +def test_view_by_id_by_image_id(session, get_value): + get_value.return_value = 20 + + img = session.view_by_id(image_id=20) + assert isinstance(img, Image) + assert img.image_id == 20 + get_value.assert_called_once_with( + "frameMap[20]", + return_path="frameInfo.fileId", + ) + + +def test_view_by_id_by_image_id_no_cross_type_fallback(session, get_value): + get_value.side_effect = CartaBadResponse("undefined") + + with pytest.raises(RuntimeError): + session.view_by_id(image_id=7) + + +def test_view_by_id_by_color_blending_id(session, get_value): + get_value.return_value = 7 + + cb = session.view_by_id(color_blending_id=7) + assert isinstance(cb, ColorBlending) + assert cb.color_blending_id == 7 + get_value.assert_called_once_with( + "imageViewConfigStore.colorBlendingImageMap[7]", + return_path="id", + ) + + +def test_view_by_id_by_color_blending_id_no_cross_type_fallback( + session, get_value +): + get_value.side_effect = CartaBadResponse("undefined") + + with pytest.raises(RuntimeError): + session.view_by_id(color_blending_id=10) + + +def test_view_by_id_uses_targeted_frontend_lookups(session, get_value): + get_value.side_effect = [ + {"type": ImageType.FRAME, "id": 10}, + 10, + 7, + ] + + session.view_by_id(view_index=0) + session.view_by_id(image_id=10) + session.view_by_id(color_blending_id=7) + + assert get_value.call_args_list == [ + call( + "imageViewConfigStore.imageListSummary[0]" + ), + call("frameMap[10]", return_path="frameInfo.fileId"), + call( + "imageViewConfigStore.colorBlendingImageMap[7]", + return_path="id", + ), + ] + + +# session.active_view + + +def test_active_view_returns_image_when_image_active(session, get_value): + get_value.side_effect = [ + {"type": ImageType.FRAME, "store": {"id": 12}}, + ] + active = session.active_view() + assert isinstance(active, Image) + assert active.image_id == 12 + assert [call.args for call in get_value.call_args_list] == [ + ("activeImage",), + ] + + +def test_active_view_returns_color_blending_when_color_blending_active( + session, get_value +): + get_value.side_effect = [ + {"type": ImageType.COLOR_BLENDING, "store": {"id": 3}}, + ] + active = session.active_view() + assert isinstance(active, ColorBlending) + assert active.color_blending_id == 3 + assert [call.args for call in get_value.call_args_list] == [ + ("activeImage",), + ] + + +def test_active_view_raises_on_unsupported_type(session, get_value): + get_value.side_effect = [ + {"type": ImageType.PV_PREVIEW, "store": {"id": -2}}, + ] + with pytest.raises(NotImplementedError): + session.active_view() + +# open_as_color_blending / create_color_blending + + +@pytest.mark.parametrize("files", [ + ["a.fits"], + ["a.fits", "b.fits"], + ["a.fits", "b.fits", "c.fits"], + ["a.fits", "b.fits", "c.fits", "d.fits"], +]) +def test_open_as_color_blending_opens_files_sets_matching_and_creates_blending( + session, mocker, files +): + images = [mocker.MagicMock(name=f"image{i}") for i in range(len(files))] + open_images = mocker.patch.object( + session, + "open_images", + return_value=images, + ) + fake_cb = mocker.MagicMock(name="ColorBlending") + create_color_blending = mocker.patch.object( + session, + "create_color_blending", + return_value=fake_cb, + ) + + result = session.open_as_color_blending(files) + open_images.assert_called_once_with(files, append=False) + images[0].make_spatial_reference.assert_called_once_with() + images[0].set_spatial_matching.assert_not_called() + for image in images[1:]: + image.set_spatial_matching.assert_called_once_with(True) + image.make_spatial_reference.assert_not_called() + create_color_blending.assert_called_once_with() + assert result is fake_cb + + +def test_open_as_color_blending_rejects_empty_file_list(session, mocker): + open_images = mocker.patch.object(session, "open_images") + create_color_blending = mocker.patch.object( + session, "create_color_blending" + ) + + with pytest.raises(CartaValidationFailed) as e: + session.open_as_color_blending([]) + + assert "at least 1" in str(e.value) + open_images.assert_not_called() + create_color_blending.assert_not_called() + + +@pytest.mark.parametrize("open_image_count,layer_count,expected_colormap_set", [ + (1, 1, ColormapSet.RGB), + (3, 3, ColormapSet.RGB), + (5, 3, ColormapSet.RGB), + (5, 4, ColormapSet.RAINBOW), +]) +def test_create_color_blending_calls_frontend_create_action(session, mocker, open_image_count, layer_count, expected_colormap_set): + get_value = mocker.patch.object( + session, + "get_value", + side_effect=[ + open_image_count, + layer_count, + ], + ) + call_action = mocker.patch.object( + session, + "call_action", + return_value=123, + ) + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + result = session.create_color_blending() + assert get_value.call_args_list == [ + mocker.call("frames.length"), + mocker.call( + "imageViewConfigStore.colorBlendingImageMap[123].frames.length", + return_path=None, + ), + ] + call_action.assert_called_once_with( + "imageViewConfigStore.createColorBlending", + return_path="id", + ) + mock_set_colormap.assert_called_once_with(expected_colormap_set) + assert isinstance(result, ColorBlending) + assert result.color_blending_id == 123 + + +def test_create_color_blending_with_images_sets_reference_and_matching( + session, mocker +): + images = [Image(session, 10), Image(session, 20), Image(session, 30)] + for image in images: + mocker.patch.object(image, "make_spatial_reference") + mocker.patch.object(image, "set_spatial_matching") + mocker.patch.object(session, "call_action", return_value=123) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + side_effect=[4, 3], + ) + delete_layer = mocker.patch.object(ColorBlending, "delete_layer") + add_layer = mocker.patch.object(ColorBlending, "add_layer") + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + result = session.create_color_blending(images=images) + + images[0].make_spatial_reference.assert_called_once_with() + images[0].set_spatial_matching.assert_not_called() + images[1].set_spatial_matching.assert_called_once_with(True) + images[2].set_spatial_matching.assert_called_once_with(True) + assert delete_layer.call_args_list == [ + mocker.call(3), + mocker.call(2), + mocker.call(1), + ] + assert add_layer.call_args_list == [ + mocker.call(images[1]), + mocker.call(images[2]), + ] + mock_set_colormap.assert_called_once_with(ColormapSet.RGB) + assert isinstance(result, ColorBlending) + + +@pytest.mark.parametrize("images", [[], [object()]]) +def test_create_color_blending_rejects_invalid_images( + session, mocker, images +): + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + +def test_create_color_blending_rejects_duplicate_images(session, mocker): + images = [Image(session, 10), Image(session, 20), Image(session, 10)] + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises( + CartaValidationFailed, + match="must not contain duplicate images", + ): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + +def test_create_color_blending_rejects_image_from_another_session( + session, mocker +): + images = [Image(session, 10), Image(Session(1, None), 20)] + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed, match="current session"): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + +def test_create_color_blending_raises_when_no_images_are_open(session, mocker): + get_value = mocker.patch.object(session, "get_value", return_value=0) + call_action = mocker.patch.object(session, "call_action") + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + with pytest.raises(CartaActionFailed, match="No images are open"): + session.create_color_blending() + + get_value.assert_called_once_with("frames.length") + call_action.assert_not_called() + mock_set_colormap.assert_not_called() + + +def test_set_cursor(session, call_action): + session.set_cursor(1, 2) + call_action.assert_called_once_with( + "activeFrame.setCursorPosition", Pt(1, 2) + ) + # OPENING IMAGES diff --git a/tests/test_wcs_overlay.py b/tests/test_wcs_overlay.py index e462b92..1f3b4f4 100644 --- a/tests/test_wcs_overlay.py +++ b/tests/test_wcs_overlay.py @@ -1,7 +1,6 @@ import pytest from carta.util import CartaValidationFailed -from carta.wcs_overlay import ImageWCSConnector from carta.constants import NumberFormat as NF, Overlay as O, CoordinateSystem as CS, PaletteColor as PC, FontFamily as FF, FontStyle as FS, LabelType as LT, ColorbarPosition as CP, BeamType as BT @@ -66,7 +65,7 @@ def image_beam_property(mock_property): @pytest.fixture def mock_images(image, mocker): - return mocker.patch.object(ImageWCSConnector, "_images", return_value=[image]) + return mocker.patch.object(image.session, "images", return_value=[image]) # TESTS