diff --git a/carta/colorblending.py b/carta/colorblending.py new file mode 100644 index 0000000..558e829 --- /dev/null +++ b/carta/colorblending.py @@ -0,0 +1,449 @@ +from .constants import Colormap, ColormapSet +from .image import Image +from .util import BasePathMixin, CartaActionFailed, Macro, cached +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 : int + The layer ID. + + Attributes + ---------- + colorblending : :obj:`carta.colorblending.ColorBlending` + The color blending object. + layer_id : int + 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 int + 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_id = self.colorblending.imageview_id + cb_name = self.colorblending.file_name + repr_content = [ + f"{session_id}:{cb_id}:{cb_name}", + f"{self.layer_id}:{self.file_name}", + ] + return ":".join(repr_content) + + @property + @cached + def file_name(self): + """The name of the image. + + Returns + ------- + string + The image name. + """ + return self.get_value("frameInfo.fileInfo.name") + + @property + @cached + def image_id(self): + """The ID of the image. + + Returns + ------- + int + 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 : float + 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 : :obj:`carta.constants.Colormap` + The colormap. + invert : bool + 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. + image_id : int + The image ID. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this color blending. + image_id : int + The image ID. + """ + + def __init__(self, session, image_id): + self.session = session + self.image_id = image_id + + path = "imageViewConfigStore.colorBlendingImages" + self._base_path = f"{path}[{self.image_id}]" + self._frame = Macro("", self._base_path) + + self.base_frame = Image(self.session, self.layer_list()[0].image_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. + """ + # 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" + image_id = session.call_action(command, return_path="id") + return cls(session, image_id) + + @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. + """ + 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 + @cached + def file_name(self): + """The name of the image. + + Returns + ------- + string + The image name. + """ + return self.get_value("filename") + + @property + @cached + def imageview_id(self): + """The ID of the image in imageView. + + Returns + ------- + integer + The image ID. + """ + imageview_names = self.session.get_value( + "imageViewConfigStore.imageNames" + ) + return imageview_names.index(self.file_name) + + @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. + """ + + def count_layers(): + idx = 0 + while True: + try: + self.get_value(f"frames[{idx}].frameInfo.fileId") + idx += 1 + except CartaActionFailed: + break + return idx + + return [Layer(self, i) for i in range(count_layers())] + + 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(1, None)) + def delete_layer(self, layer_index): + """Delete a layer from the color blending. + + Parameters + ---------- + layer_index : int + The layer index. The base layer (layer_index = 0) 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 : :obj:`carta.image.Image` + The image to set. + layer_index : int + The layer index. The base layer (layer_index = 0) cannot + be set. + """ + self.call_action("setSelectedFrame", layer_index - 1, image._frame) + + @validate(IterableOf(Number(1, None), min_size=2)) + def reorder_layers(self, order_list): + """Reorder the layers in the color blending. + + Parameters + ---------- + order_list : list of int + The list of layer indices in the desired order. The list must not + contain the base layer (index = 0). + """ + layers = self.layer_list() + image_ids = [layer.image_id for layer in layers] + # Delete all layers except the base layer + for _ in layers[1:]: + # Delete the first layer + # The previous second layer becomes the first layer + self.delete_layer(1) + for idx in order_list: + image = Image(self.session, image_ids[idx]) + self.add_layer(image) + + @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.set_coordinate_system` and + :obj:`set_custom_number_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 : :obj:`carta.constants.ColormapSet` + The colormap set. + """ + self.call_action("applyColormapSet", colormap_set) + for layer in self.layer_list(): + layer.call_action("renderConfig.setInverted", False) + + @validate(IterableOf(Number(0, 1))) + def set_alpha(self, alpha_list): + """Set the alpha value for the color blending layers. + + Parameters + ---------- + alpha_list : list of float + The alpha values. + """ + layer_list = self.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 : bool + 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 : bool + The desired visibility state. + """ + is_visible = self.get_value("contourVisible") + if is_visible != state: + self.call_action("toggleContourVisible") + + @validate(Boolean()) + def set_vectoroverlay_visible(self, state): + """Set the vector overlay visibility. + + Parameters + ---------- + state : bool + 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..dd3576d 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -23,6 +23,13 @@ class ComplexComponent(StrEnum): Colormap.__doc__ = """All available colormaps.""" +class ColormapSet(StrEnum): + """Colormap sets for color blending.""" + RGB = "RGB" + CMY = "CMY" + Rainbow = "Rainbow" + + 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..12fef67 100644 --- a/carta/image.py +++ b/carta/image.py @@ -5,11 +5,10 @@ from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin, Point as Pt +from .util import Macro, cached, BasePathMixin, CartaActionFailed, 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 @@ -256,7 +255,12 @@ def polarizations(self): def make_active(self): """Make this the active image.""" - self.session.call_action("setActiveFrameById", self.image_id) + try: + # Before CARTA 5.0.0 + self.session.call_action("setActiveFrameById", self.image_id) + except CartaActionFailed: + # After CARTA 5.0.0 (inclusive) + self.session.call_action("setActiveImageByFileId", self.image_id) def make_spatial_reference(self): """Make this image the spatial reference.""" 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..dbb01c8 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,97 @@ 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 (id = 0) cannot be deleted or reordered. + 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 (id = 0) cannot be deleted or reordered. + cb = ColorBlending.from_images(session, [img0, img1, img2]) + +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]) + + # Reorder layers (except the base layer) + # Since the base layer (id = 0) cannot be reordered, + # the layers will be reordered as [img0, img2, img1] + cb.reorder_layers([2, 1]) + + # Remove the last layer (id = 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:: + When you would like to reorder the layers, especially when the base layer (id = 0) is involved, it is more recommended to 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..9cb78c1 --- /dev/null +++ b/tests/test_colorblending.py @@ -0,0 +1,376 @@ +import pytest + +from carta.colorblending import ColorBlending, Layer +from carta.constants import Colormap as CM +from carta.constants import ColormapSet as CMS +from carta.image import Image +from carta.util import CartaActionFailed, CartaValidationFailed, Macro + +# FIXTURES + + +@pytest.fixture +def colorblending(session, mocker): + # Avoid hitting real layer_list logic during __init__ + class _Dummy: + def __init__(self, image_id): + self.image_id = image_id + + mocker.patch.object(ColorBlending, "layer_list", return_value=[_Dummy(42)]) + 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) + + +# TESTS — ColorBlending basics + + +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, cb_property +): + cb_property("file_name", "imgC") + session_get_value.side_effect = [["imgA", "imgB", "imgC", "imgD"]] + assert colorblending.imageview_id == 2 + session_get_value.assert_called_with("imageViewConfigStore.imageNames") + + +def test_colorblending_alpha(colorblending, cb_get_value): + colorblending.alpha + cb_get_value.assert_called_with("alpha") + + +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): + # Construct without running __init__ to avoid base_frame wiring + cb = object.__new__(ColorBlending) + cb.session = session + cb.image_id = 0 + cb._base_path = f"imageViewConfigStore.colorBlendingImages[{cb.image_id}]" + cb._frame = Macro("", cb._base_path) + + # Simulate two layers and then failure for third + gv = mocker.patch.object(cb, "get_value") + gv.side_effect = [ + 1, + 2, + CartaActionFailed("stop"), + ] # fileIds for idx 0,1 then fail + + layers = cb.layer_list() + assert [ly.layer_id for ly in layers] == [0, 1] + + +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) + + +@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_reorder_layers(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)], + ) + del_layer = mocker.patch.object(colorblending, "delete_layer") + add_layer = mocker.patch.object(colorblending, "add_layer") + + colorblending.reorder_layers([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] + + +def test_colorblending_set_center(colorblending, mocker): + set_center = mocker.patch.object(colorblending.base_frame, "set_center") + colorblending.set_center(1, 2) + set_center.assert_called_with(1, 2) + + +@pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) +def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): + set_zoom = mocker.patch.object(colorblending.base_frame, "set_zoom_level") + colorblending.set_zoom_level(zoom, absolute) + set_zoom.assert_called_with(zoom, absolute) + + +def test_colorblending_set_colormap_set(colorblending, cb_call_action, mocker): + # Two layers; verify setInverted(False) called on each + 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_colormap_set(CMS.Rainbow) + cb_call_action.assert_called_with("applyColormapSet", CMS.Rainbow) + ly1.call_action.assert_called_with("renderConfig.setInverted", False) + ly2.call_action.assert_called_with("renderConfig.setInverted", False) + + +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( + "getter,method,action,state", + [ + ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), + ( + "contourVisible", + "set_contour_visible", + "toggleContourVisible", + True, + ), + ( + "vectorOverlayVisible", + "set_vectoroverlay_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_vectoroverlay_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_images_success(session, mocker): + # Prepare two images to blend + img0 = Image(session, 100) + img1 = Image(session, 200) + + # setSpatialReference alignment returns True for img1 + mocker.patch.object(session, "call_action") + mocker.patch.object(img1, "call_action", return_value=True) + + # Create ID for new color blending + session.call_action.side_effect = [None, 123] + + # Avoid __init__ side effects; just ensure returned instance + mocker.patch.object(ColorBlending, "__init__", return_value=None) + cb = ColorBlending.from_images(session, [img0, img1]) + assert isinstance(cb, ColorBlending) + session.call_action.assert_any_call( + "setSpatialReference", img0._frame, False + ) + img1.call_action.assert_called_with("setSpatialReference", img0._frame) + session.call_action.assert_called_with( + "imageViewConfigStore.createColorBlending", return_path="id" + ) + + +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_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"