diff --git a/carta/colorblending.py b/carta/colorblending.py new file mode 100644 index 0000000..c6d5bc0 --- /dev/null +++ b/carta/colorblending.py @@ -0,0 +1,556 @@ +"""This module contains functionality for interacting with color blending images and their layers.""" + +from .constants import Colormap, ColormapSet, ImageType +from .image import Image +from .util import BasePathMixin, CartaActionFailed, Macro +from .validation import ( + Boolean, + Constant, + Coordinate, + InstanceOf, + IterableOf, + Number, + validate, +) + + +class Layer(BasePathMixin): + """This object represents a single layer in a color blending object. + + Parameters + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : integer + The layer ID. + + Attributes + ---------- + colorblending : :obj:`carta.colorblending.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, colorblending, layer_id): + self.colorblending = colorblending + self.layer_id = layer_id + self.session = colorblending.session + + self._base_path = f"{self.colorblending._base_path}.frames[{layer_id}]" + self._frame = Macro("", self._base_path) + + @classmethod + def from_list(cls, colorblending, layer_ids): + """ + Create a list of Layer objects from a list of layer IDs. + + Parameters + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_ids : list of integer + The layer IDs. + + Returns + ------- + list of :obj:`carta.colorblending.Layer` + A list of new Layer objects. + """ + return [cls(colorblending, layer_id) for layer_id in layer_ids] + + def __repr__(self): + """A human-readable representation of this object.""" + session_id = self.session.session_id + cb_imageview_id = self.colorblending.imageview_id + cb_name = self.colorblending.file_name + repr_content = [ + f"{session_id}:{cb_imageview_id}:{cb_name}", + f"{self.layer_id}:{self.file_name}", + ] + return ":".join(repr_content) + + @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 ID of the image. + + Returns + ------- + integer + The image ID. + """ + return self.get_value("frameInfo.fileId") + + @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.colorblending.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(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. + store_id : integer + The color blending store ID of the color blending image. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this color blending. + store_id : integer + The color blending store ID of the color blending image. + """ + + # Mirrors ColorBlendingStore.DEFAULT_LAYER_LIMIT in carta-frontend. + MAX_INITIAL_LAYERS = 10 + + def __init__(self, session, store_id): + self.session = session + self.store_id = store_id + + path = "imageViewConfigStore.colorBlendingImageMap" + self._base_path = f"{path}[{self.store_id}]" + self._frame = Macro("", self._base_path) + + @classmethod + def _validate_initial_layer_count(cls, layer_count): + if layer_count > cls.MAX_INITIAL_LAYERS: + raise ValueError( + "Color blending initialization supports at most " + f"{cls.MAX_INITIAL_LAYERS} images (the base layer plus " + f"{cls.MAX_INITIAL_LAYERS - 1} matched images)." + ) + + @classmethod + def from_imageview_id(cls, session, imageview_id): + """Create a color blending object from an image view ID. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object. + imageview_id : integer + The image view ID, the index of the image within the list of + currently open images, of the color blending image. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + A new color blending object. + """ + # Find the store ID for the given image view ID + path = f"imageViewConfigStore.imageList[{imageview_id}]" + image_type = session.get_value(f"{path}.type") + if image_type != ImageType.COLOR_BLENDING: + raise ValueError( + "imageview_id does not refer to a color blending image." + ) + store_id = session.get_value(f"{path}.store.id") + return cls(session, store_id) + + @classmethod + def from_images(cls, session, images): + """Create a color blending object from a list of images. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object. + images : list of :obj:`carta.image.Image` + The images to be blended. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + A new color blending object. + + Raises + ------ + ValueError + If more images are provided than the frontend can include when + initializing the color blending layers. + """ + cls._validate_initial_layer_count(len(images)) + + # Set the first image as the spatial reference + session.call_action("setSpatialReference", images[0]._frame, False) + # Align the other images to the spatial reference + for image in images[1:]: + success = image.call_action( + "setSpatialReference", images[0]._frame + ) + if not success: + name = image.file_name + raise CartaActionFailed( + f"Failed to set spatial reference for image {name}." + ) + + command = "imageViewConfigStore.createColorBlending" + store_id = session.call_action(command, return_path="id") + colorblending = cls(session, store_id) + + # The frontend initializes color blending from the current spatial + # reference's secondarySpatialImages, which can include frames matched + # before this helper was called. Rebuild the non-base layers so the + # blend contains exactly the images requested here without clearing the + # session-wide spatial matching state. + for _ in colorblending.layer_list()[1:]: + colorblending.delete_layer(1) + for image in images[1:]: + colorblending.add_layer(image) + + return colorblending + + @classmethod + def from_files(cls, session, files, append=False): + """Create a color blending object from a list of files. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object. + files : list of string + The files to be blended. + append : bool + Whether the images should be appended to existing images. + By default this is ``False`` and any existing open images + are closed. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + A new color blending object. + """ + cls._validate_initial_layer_count(len(files)) + images = session.open_images(files, append=append) + return cls.from_images(session, images) + + def __repr__(self): + """A human-readable representation of this color blending object.""" + session_id = self.session.session_id + return f"{session_id}:{self.imageview_id}:{self.file_name}" + + @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") + + @property + def imageview_id(self): + """The image view ID of the color blending image. + + Returns + ------- + integer + The image view ID. + """ + path = "imageViewConfigStore.imageList" + length = self.session.get_value(f"{path}.length") + for idx in range(length): + entry = f"{path}[{idx}]" + entry_type = self.session.get_value(f"{entry}.type") + if entry_type != ImageType.COLOR_BLENDING: + continue + entry_id = self.session.get_value(f"{entry}.store.id") + if entry_id == self.store_id: + return idx + raise RuntimeError( + "Could not find this color blending image in the image list." + ) + + @property + def alpha(self): + """The alpha value list for the color blending layers. + + Returns + ------- + list of float + The alpha values. + """ + return self.get_value("alpha") + + def make_active(self): + """Make this the active image.""" + self.session.call_action("setActiveImageByIndex", self.imageview_id) + + def layer_list(self): + """ + Returns a list of Layer objects, each representing a layer in + this color blending object. + + Returns + ------- + list of :obj:`carta.colorblending.Layer` + A list of Layer objects. + """ + layer_count = self.get_value("frames.length") + return Layer.from_list(self, list(range(layer_count))) + + 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(Number(0, None)) + def delete_layer(self, layer_index): + """Delete a layer from the color blending. + + Parameters + ---------- + layer_index : {0} + The layer index. The base layer (layer_index = 0) cannot + be deleted. + """ + if layer_index == 0: + raise ValueError("The base layer cannot be deleted.") + self.call_action("deleteSelectedFrame", layer_index - 1) + + @validate(InstanceOf(Image), Number(1, None)) + def set_layer(self, image, layer_index): + """Set a layer at a specified index in the color blending. + + Parameters + ---------- + image : {0} + The image to set. + layer_index : {1} + The layer index. The base layer (layer_index = 0) cannot + be set. + """ + self.call_action("setSelectedFrame", layer_index - 1, image._frame) + + @validate(IterableOf(Number(0, None), min_size=1)) + def set_layer_sequence(self, layer_indices): + """Set which layers are included in the color blending and in what + order. + + Parameters + ---------- + layer_indices : {0} + The layer indices to keep, in the desired order. The first index + must be the base layer (index = 0). Existing alpha values are + preserved. + """ + current_layers = self.layer_list() + max_current_layer_index = len(current_layers) - 1 + if any( + layer_index > max_current_layer_index + for layer_index in layer_indices + ): + raise ValueError( + "layer_indices contains a layer index which does not exist." + ) + + if layer_indices[0] != 0: + raise ValueError( + "layer_indices must start with the base layer index 0." + ) + + if 0 in layer_indices[1:]: + raise ValueError( + "layer_indices must contain the base layer index 0 only once, " + "as the first index." + ) + + if len(layer_indices) != len(set(layer_indices)): + raise ValueError( + "layer_indices must not contain duplicate layer indices." + ) + + current_layer_indices = list(range(len(current_layers))) + if list(layer_indices) == current_layer_indices: + return + + current_alpha_values = self.alpha + target_layer_states = [ + ( + Image(self.session, current_layers[layer_index].image_id), + current_alpha_values[layer_index], + ) + for layer_index in layer_indices[1:] + ] + + # Delete all layers except the base layer + for _ in current_layers[1:]: + # Delete layer at index 1 (the first non-base layer); + # after deletion, the previous layer at index 2 shifts to index 1 + self.delete_layer(1) + + for target_layer_index, (image, alpha) in enumerate( + target_layer_states, start=1 + ): + self.add_layer(image) + Layer(self, target_layer_index).set_alpha(alpha) + + @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.wcs_overlay.Global.set_coordinate_system` and + :obj:`carta.wcs_overlay.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(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) + + @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) + + @validate(IterableOf(Number(0, 1))) + def set_alpha(self, alpha_list): + """Set the alpha value for the color blending layers. + + Parameters + ---------- + alpha_list : {0} + The alpha values. + """ + layer_list = self.layer_list() + if len(alpha_list) != len(layer_list): + raise ValueError( + f"alpha_list length ({len(alpha_list)}) does not match " + f"the number of layers ({len(layer_list)})." + ) + for alpha, layer in zip(alpha_list, layer_list): + layer.set_alpha(alpha) + + @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") + + 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..0cdf183 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): + """Image 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..ea3945f 100644 --- a/carta/image.py +++ b/carta/image.py @@ -9,7 +9,6 @@ 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 @@ -256,7 +255,7 @@ def polarizations(self): def make_active(self): """Make this the active image.""" - self.session.call_action("setActiveFrameById", self.image_id) + self.session.call_action("setActiveImageByFileId", self.image_id) def make_spatial_reference(self): """Make this image the spatial reference.""" @@ -362,7 +361,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.wcs_overlay.Global.set_coordinate_system` and :obj:`carta.wcs_overlay.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..e924ccd 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,6 +10,7 @@ import posixpath from .image import Image +from .colorblending import ColorBlending from .constants import PanelMode, GridMode, ComplexComponent, Polarization from .backend import Backend from .protocol import Protocol @@ -520,6 +521,19 @@ def image_list(self): """ return Image.from_list(self, self.get_value("frameNames")) + def color_blending_list(self): + """Return the list of currently open color blending objects. + + Returns + ------- + list of :obj:`carta.colorblending.ColorBlending` objects + The list of color blending objects open in this session. + """ + path = "imageViewConfigStore.colorBlendingImages" + length = self.get_value(f"{path}.length") + store_ids = [self.get_value(f"{path}[{idx}].id") for idx in range(length)] + return [ColorBlending(self, store_id) for store_id in store_ids] + def active_frame(self): """Return the currently active image. diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 54998fe..ed393e5 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -17,6 +17,14 @@ carta.browser module :undoc-members: :show-inheritance: +carta.colorblending module +-------------------------- + +.. automodule:: carta.colorblending + :members: + :undoc-members: + :show-inheritance: + carta.constants module ---------------------- diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index ac8bbec..d6b6874 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -171,8 +171,9 @@ 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) Changing image properties ------------------------- @@ -192,7 +193,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) @@ -225,7 +226,117 @@ 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 +-------------------------- + +Create a color blending object from a list of files: + +.. code-block:: python + + from carta.colorblending import ColorBlending + from carta.constants import Colormap, ColormapSet + + # Make a color blending object + # Warning: setting `append=False` will close any existing images + # Note: The base layer (index = 0) cannot be deleted or moved. + files = [ + "data/hdf5/first_file.hdf5", + "data/fits/second_file.fits", + "data/fits/third_file.fits", + ] + cb = ColorBlending.from_files(session, files, append=False) + +Create a color blending object from a list of images: + +.. code-block:: python + + from carta.colorblending import ColorBlending + from carta.constants import Colormap, ColormapSet + + # Make a color blending object + # Warning: This will break the current spatial matching and + # use the first image as the spatial reference + # Note: The base layer (index = 0) cannot be deleted or moved. + cb = ColorBlending.from_images(session, [img0, img1, img2]) + +To work with color blending images that are already open in a session, use +the session helper: + +.. code-block:: python + + # Get all open color blending objects in this session + color_blendings = session.color_blending_list() + cb = color_blendings[0] + + # Or get a color blending object by its image view index + cb = ColorBlending.from_imageview_id(session, 3) + +.. note:: + The ``ColorBlending`` constructor takes the internal color blending store ID, + not the image view index. Use ``ColorBlending.from_files``, + ``ColorBlending.from_images``, ``ColorBlending.from_imageview_id`` or + ``session.color_blending_list`` in scripts. + +Manipulate properties of the color blending object and the underlying images: + +.. code-block:: python + + # Get layer objects + layers = cb.layer_list() + + # Set colormap for individual layers + layers[0].set_colormap(Colormap.REDS) + layers[1].set_colormap(Colormap.GREENS) + layers[2].set_colormap(Colormap.BLUES) + + # Or apply an existing colormap set + cb.set_colormap_set(ColormapSet.RGB) + + # Print the current alpha values of all layers + print(cb.alpha) + + # Set alpha for individual layers + layers[0].set_alpha(0.7) + layers[1].set_alpha(0.8) + layers[2].set_alpha(0.9) + + # Or set alpha for all layers at once + cb.set_alpha([0.7, 0.8, 0.9]) + + # Set which layers to keep, and in what order + # The first layer index must be the base layer (index = 0) + # Since the base layer cannot be moved, + # the layers will be reordered as [img0, img2, img1] + cb.set_layer_sequence([0, 2, 1]) + + # Remove the last layer (index = 2) + cb.delete_layer(2) + + # Add a new layer + # The layer to be added cannot be one of the current layers + cb.add_layer(img1) + + # Set center + cb.set_center(100, 100) + + # Set zoom level + cb.set_zoom_level(2) + + # Set the color blending object as the active frame + 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:: + If you need to change the layer order involving the base layer (index = 0), + close the current color blending object and create a new one. + Saving or displaying an image ----------------------------- diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py new file mode 100644 index 0000000..61f8ef3 --- /dev/null +++ b/tests/test_colorblending.py @@ -0,0 +1,632 @@ +import pytest + +from carta.colorblending import ColorBlending, Layer +from carta.constants import Colormap as CM +from carta.constants import ColormapSet as CMS +from carta.constants import ImageType +from carta.image import Image +from carta.util import CartaActionFailed, CartaValidationFailed, Macro + +# FIXTURES + + +@pytest.fixture +def colorblending(session): + return ColorBlending(session, 0) + + +@pytest.fixture +def layer(colorblending): + return Layer(colorblending, 1) + + +@pytest.fixture +def cb_get_value(colorblending, mock_get_value): + return mock_get_value(colorblending) + + +@pytest.fixture +def cb_call_action(colorblending, mock_call_action): + return mock_call_action(colorblending) + + +@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.colorblending.ColorBlending") + + +@pytest.fixture +def layer_property(mock_property): + return mock_property("carta.colorblending.Layer") + + +# TESTS — Layer + + +def test_layer_from_list(colorblending): + layers = Layer.from_list(colorblending, [5, 6, 7]) + assert [ly.layer_id for ly in layers] == [5, 6, 7] + assert all(ly.colorblending is colorblending for ly in layers) + + +def test_layer_repr(session, colorblending, cb_property, layer_property): + cb_property("imageview_id", 11) + cb_property("file_name", "blend.fits") + layer_property("file_name", "layer1.fits") + r = repr(Layer(colorblending, 3)) + # session id is 0 (from conftest) + assert r == "0:11:blend.fits:3:layer1.fits" + + +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") + + +@pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0]) +def test_layer_set_alpha_valid(colorblending, alpha, cb_call_action): + Layer(colorblending, 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(colorblending, alpha): + with pytest.raises(CartaValidationFailed): + Layer(colorblending, 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_colorblending_init(session): + colorblending = ColorBlending(session, 3) + assert colorblending.store_id == 3 + expected = "imageViewConfigStore.colorBlendingImageMap[3]" + assert colorblending._base_path == expected + assert colorblending._frame == Macro( + "", "imageViewConfigStore.colorBlendingImageMap[3]" + ) + + +def test_colorblending_repr(session, colorblending, cb_property): + cb_property("imageview_id", 3) + cb_property("file_name", "blend.fits") + assert repr(colorblending) == "0:3:blend.fits" + + +def test_colorblending_file_name(colorblending, cb_get_value): + colorblending.file_name + cb_get_value.assert_called_with("filename") + + +def test_colorblending_imageview_id(session, colorblending, session_get_value): + # imageList has 3 entries; the color blending with store_id=0 is at index 2 + session_get_value.side_effect = [ + 3, # imageList.length + ImageType.FRAME, # [0].type — skip + ImageType.COLOR_BLENDING, # [1].type — match type… + 99, # [1].store.id — wrong store_id + ImageType.COLOR_BLENDING, # [2].type — match type… + 0, # [2].store.id — matches store_id=0 + ] + assert colorblending.imageview_id == 2 + + +def test_colorblending_alpha(colorblending, cb_get_value): + colorblending.alpha + cb_get_value.assert_called_with("alpha") + + +def test_colorblending_base_frame(colorblending, cb_get_value): + cb_get_value.return_value = 42 + base_frame = colorblending._base_frame + + cb_get_value.assert_called_once_with("frames[0].id") + assert isinstance(base_frame, Image) + assert base_frame.session is colorblending.session + assert base_frame.image_id == 42 + + +def test_colorblending_make_active( + session, colorblending, cb_property, session_call_action +): + cb_property("imageview_id", 9) + colorblending.make_active() + session_call_action.assert_called_with("setActiveImageByIndex", 9) + + +def test_colorblending_layer_list_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.layer_list() + assert [ly.layer_id for ly in layers] == [0, 1] + gv.assert_called_once_with("frames.length") + + +def test_colorblending_add_layer(colorblending, cb_call_action, image): + colorblending.add_layer(image) + cb_call_action.assert_called_with("addSelectedFrame", image._frame) + + +@pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) +def test_colorblending_delete_layer( + colorblending, cb_call_action, idx, expected_param +): + colorblending.delete_layer(idx) + cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) + + +def test_colorblending_delete_layer_rejects_base_layer( + colorblending, cb_call_action +): + with pytest.raises(ValueError, match="The base layer cannot be deleted."): + colorblending.delete_layer(0) + + cb_call_action.assert_not_called() + + +@pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) +def test_colorblending_set_layer( + colorblending, cb_call_action, image, idx, expected_param +): + colorblending.set_layer(image, idx) + cb_call_action.assert_called_with( + "setSelectedFrame", expected_param, image._frame + ) + + +def test_colorblending_set_layer_sequence(session, colorblending, mocker): + # Prepare three existing layers with image_ids 10, 20, 30 + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + return_value=[1.0, 0.2, 0.8], + ) + del_layer = mocker.patch.object(colorblending, "delete_layer") + add_layer = mocker.patch.object(colorblending, "add_layer") + set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) + + colorblending.set_layer_sequence([0, 2, 1]) + + # Deletes all non-base layers (twice) then adds layers in specified order + assert del_layer.call_count == 2 + add_args = [call.args[0] for call in add_layer.call_args_list] + assert [img.image_id for img in add_args] == [30, 20] + assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] + + +def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], + ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + return_value=[1.0, 0.2, 0.8, 0.4], + ) + del_layer = mocker.patch.object(colorblending, "delete_layer") + add_layer = mocker.patch.object(colorblending, "add_layer") + set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) + + colorblending.set_layer_sequence([0, 3, 1]) + + assert del_layer.call_count == 3 + assert [call.args[0].image_id for call in add_layer.call_args_list] == [40, 20] + assert [call.args[1] for call in set_alpha.call_args_list] == [0.4, 0.2] + + +def test_colorblending_set_layer_sequence_rejects_missing_layer_index( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], + ) + + with pytest.raises( + ValueError, + match="layer_indices contains a layer index which does not exist.", + ): + colorblending.set_layer_sequence([0, 4, 1]) + + +def test_colorblending_set_layer_sequence_requires_base_layer_first( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match="layer_indices must start with the base layer index 0.", + ): + colorblending.set_layer_sequence([2, 1]) + + +def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match=( + "layer_indices must contain the base layer index 0 only once, " + "as the first index." + ), + ): + colorblending.set_layer_sequence([0, 2, 0]) + + +def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match="layer_indices must not contain duplicate layer indices.", + ): + colorblending.set_layer_sequence([0, 1, 1]) + + +def test_colorblending_set_center(colorblending, mocker): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + colorblending.set_center(1, 2) + base_frame.set_center.assert_called_once_with(1, 2) + + +@pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) +def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + colorblending.set_zoom_level(zoom, absolute) + base_frame.set_zoom_level.assert_called_once_with(zoom, absolute) + + +def test_colorblending_set_colormap_set(colorblending, cb_call_action): + colorblending.set_colormap_set(CMS.RAINBOW) + cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) + + +def test_colorblending_set_alpha_valid(colorblending, mocker): + ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) + mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + + colorblending.set_alpha([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_colorblending_set_alpha_invalid(colorblending, vals): + with pytest.raises(CartaValidationFailed): + colorblending.set_alpha(vals) + + +@pytest.mark.parametrize("vals", [[0.5], [0.1, 0.2, 0.3]]) +def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): + ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) + mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + + with pytest.raises(ValueError, match="does not match"): + colorblending.set_alpha(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_colorblending_toggle_visibility_when_needed( + colorblending, 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(colorblending, 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_colorblending_toggle_visibility_noop( + colorblending, cb_get_value, cb_call_action, getter, method, action, state +): + # Current state equals desired -> no toggle + cb_get_value.side_effect = [state] + getattr(colorblending, method)(state) + cb_call_action.assert_not_called() + + +def test_colorblending_close(session, colorblending, session_call_action): + colorblending.close() + session_call_action.assert_called_with( + "imageViewConfigStore.removeColorBlending", colorblending._frame + ) + + +# CREATION HELPERS + + +def test_colorblending_from_imageview_id(session, session_get_value): + session_get_value.side_effect = [ImageType.COLOR_BLENDING, 17] + + cb = ColorBlending.from_imageview_id(session, 5) + + assert isinstance(cb, ColorBlending) + assert cb.store_id == 17 + expected = "imageViewConfigStore.colorBlendingImageMap[17]" + assert cb._base_path == expected + assert [call.args for call in session_get_value.call_args_list] == [ + ("imageViewConfigStore.imageList[5].type",), + ("imageViewConfigStore.imageList[5].store.id",), + ] + + +def test_colorblending_from_imageview_id_rejects_non_color_blending( + session, session_get_value, mocker +): + session_get_value.return_value = ImageType.FRAME + init = mocker.patch.object(ColorBlending, "__init__", return_value=None) + + with pytest.raises( + ValueError, + match="imageview_id does not refer to a color blending image.", + ): + ColorBlending.from_imageview_id(session, 5) + + session_get_value.assert_called_once_with( + "imageViewConfigStore.imageList[5].type" + ) + init.assert_not_called() + + +def test_colorblending_from_images_success(session, mocker): + img0 = Image(session, 100) + img1 = Image(session, 200) + img2 = Image(session, 300) + + mocker.patch.object(session, "call_action") + mocker.patch.object(img1, "call_action", return_value=True) + mocker.patch.object(img2, "call_action", return_value=True) + layer_list = mocker.patch.object( + ColorBlending, + "layer_list", + autospec=True, + return_value=[object(), object(), object(), object()], + ) + delete_layer = mocker.patch.object( + ColorBlending, "delete_layer", autospec=True + ) + add_layer = mocker.patch.object(ColorBlending, "add_layer", autospec=True) + + session.call_action.side_effect = [None, 123] + + cb = ColorBlending.from_images(session, [img0, img1, img2]) + + assert isinstance(cb, ColorBlending) + assert cb.store_id == 123 + assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" + session.call_action.assert_any_call( + "setSpatialReference", img0._frame, False + ) + img1.call_action.assert_called_with("setSpatialReference", img0._frame) + img2.call_action.assert_called_with("setSpatialReference", img0._frame) + session.call_action.assert_called_with( + "imageViewConfigStore.createColorBlending", return_path="id" + ) + layer_list.assert_called_once_with(cb) + delete_layer.assert_has_calls( + [mocker.call(cb, 1), mocker.call(cb, 1), mocker.call(cb, 1)] + ) + add_layer.assert_has_calls([mocker.call(cb, img1), mocker.call(cb, img2)]) + + +def test_colorblending_from_images_alignment_failure( + session, mocker, mock_property +): + img0 = Image(session, 100) + img1 = Image(session, 200) + + mocker.patch.object(session, "call_action") + mock_property("carta.image.Image")("file_name", "bad.fits") + mocker.patch.object(img1, "call_action", return_value=False) + + with pytest.raises(CartaActionFailed) as e: + ColorBlending.from_images(session, [img0, img1]) + assert "Failed to set spatial reference for image bad.fits." in str( + e.value + ) + + +def test_colorblending_from_images_rejects_more_than_initial_layer_limit( + session, mocker +): + images = [ + Image(session, image_id) + for image_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) + ] + session_call_action = mocker.patch.object(session, "call_action") + + with pytest.raises( + ValueError, + match=( + "Color blending initialization supports at most 10 images " + r"\(the base layer plus 9 matched images\)." + ), + ): + ColorBlending.from_images(session, images) + + session_call_action.assert_not_called() + + +def test_colorblending_from_files(session, mocker): + mock_open_images = mocker.patch.object( + session, + "open_images", + return_value=[Image(session, 1), Image(session, 2)], + ) + mock_from_images = mocker.patch.object( + ColorBlending, "from_images", return_value="CB" + ) + out = ColorBlending.from_files(session, ["a.fits", "b.fits"], append=True) + mock_open_images.assert_called_with(["a.fits", "b.fits"], append=True) + mock_from_images.assert_called() + assert out == "CB" + + +def test_colorblending_from_files_rejects_more_than_initial_layer_limit( + session, mocker +): + files = [ + f"image-{file_id}.fits" + for file_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) + ] + mock_open_images = mocker.patch.object(session, "open_images") + + with pytest.raises( + ValueError, + match=( + "Color blending initialization supports at most 10 images " + r"\(the base layer plus 9 matched images\)." + ), + ): + ColorBlending.from_files(session, files) + + mock_open_images.assert_not_called() diff --git a/tests/test_image.py b/tests/test_image.py index 9a1c275..e8bf392 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -110,7 +110,7 @@ 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("setActiveImageByFileId", 0) @pytest.mark.parametrize("channel", [0, 10, 19]) diff --git a/tests/test_session.py b/tests/test_session.py index cf79849..3fb07db 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,6 +1,7 @@ import pytest from carta.image import Image +from carta.colorblending import ColorBlending from carta.util import Macro from carta.constants import ComplexComponent as CC, Polarization as Pol @@ -69,6 +70,33 @@ def test_cd(session, method, call_action): session.cd("original/path") call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") + +def test_color_blending_list(session, get_value): + get_value.side_effect = [2, 3, 8] + + color_blendings = session.color_blending_list() + + assert len(color_blendings) == 2 + assert all(isinstance(cb, ColorBlending) for cb in color_blendings) + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages.length") + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[0].id") + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[1].id") + assert [cb.session for cb in color_blendings] == [session, session] + assert [cb.store_id for cb in color_blendings] == [3, 8] + assert [cb._base_path for cb in color_blendings] == [ + "imageViewConfigStore.colorBlendingImageMap[3]", + "imageViewConfigStore.colorBlendingImageMap[8]", + ] + + +def test_color_blending_list_empty(session, get_value): + get_value.return_value = 0 + + assert session.color_blending_list() == [] + get_value.assert_called_once_with( + "imageViewConfigStore.colorBlendingImages.length" + ) + # OPENING IMAGES