From 6087d69d8bfc818630fd73499260f52769fb8b05 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 30 Apr 2025 16:38:15 +0800 Subject: [PATCH 1/7] Add ColormapSet for color blending --- carta/constants.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/carta/constants.py b/carta/constants.py index 12e72d0..14391af 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.""" From d6961b202eaaaacf68181bc6e0a5f6af56a8153c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 30 Apr 2025 18:13:07 +0800 Subject: [PATCH 2/7] Update Image.make_active to support CARTA 5.0.0+ API changes --- carta/image.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/carta/image.py b/carta/image.py index 0567520..7038701 100644 --- a/carta/image.py +++ b/carta/image.py @@ -3,14 +3,15 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ -from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin -from .units import AngularSize, WorldCoordinate -from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr +from .constants import (Polarization, SpatialAxis, SpectralSystem, + SpectralType, SpectralUnit) +from .contours import Contours from .metadata import parse_header - from .raster import Raster -from .contours import Contours +from .units import AngularSize, WorldCoordinate +from .util import BasePathMixin, CartaActionFailed, Macro, cached +from .validation import (Attr, Attrs, Boolean, Constant, Coordinate, Evaluate, + NoneOr, Number, OneOf, Size, validate) from .vector_overlay import VectorOverlay from .wcs_overlay import ImageWCSOverlay @@ -251,7 +252,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.""" From 9fa89de21cebe0d2ca63f2a65d347535368e35f4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 5 May 2025 12:07:19 +0800 Subject: [PATCH 3/7] Add ColorBlending class with layer management and color blending functionality --- carta/colorblending.py | 414 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 carta/colorblending.py diff --git a/carta/colorblending.py b/carta/colorblending.py new file mode 100644 index 0000000..bf2ff16 --- /dev/null +++ b/carta/colorblending.py @@ -0,0 +1,414 @@ +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) + + 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) From 85b3528714f994fdb80ee88e07fd4ed53c63439b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 5 May 2025 12:07:28 +0800 Subject: [PATCH 4/7] Add color blending documentation and update image handling examples --- docs/source/quickstart.rst | 78 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index ac8bbec..98d53ec 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,76 @@ 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 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]) + + # 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 ----------------------------- From 06445bc935cb402bc162b0df36f673887493b9ee Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 10 Sep 2025 16:00:56 +0800 Subject: [PATCH 5/7] Add ColorBlending.from_files method to create blended images directly from file paths --- carta/colorblending.py | 91 ++++++++++++++++++++++++++------------ docs/source/quickstart.rst | 21 +++++++++ 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index bf2ff16..e9cfd45 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,29 +1,37 @@ 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) +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. + ` + 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. + 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 @@ -58,7 +66,7 @@ def __repr__(self): cb_name = self.colorblending.file_name repr_content = [ f"{session_id}:{cb_id}:{cb_name}", - f"{self.layer_id}:{self.file_name}" + f"{self.layer_id}:{self.file_name}", ] return ":".join(repr_content) @@ -108,10 +116,8 @@ def set_colormap(self, colormap, invert=False): 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) + self.call_action("renderConfig.setColorMap", colormap) + self.call_action("renderConfig.setInverted", invert) class ColorBlending(BasePathMixin): @@ -131,6 +137,7 @@ class ColorBlending(BasePathMixin): image_id : int The image ID. """ + def __init__(self, session, image_id): self.session = session self.image_id = image_id @@ -139,8 +146,7 @@ def __init__(self, session, image_id): 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) + self.base_frame = Image(self.session, self.layer_list()[0].image_id) @classmethod def from_images(cls, session, images): @@ -163,16 +169,41 @@ def from_images(cls, session, images): # Align the other images to the spatial reference for image in images[1:]: success = image.call_action( - "setSpatialReference", images[0]._frame) + "setSpatialReference", images[0]._frame + ) if not success: name = image.file_name raise CartaActionFailed( - f"Failed to set spatial reference for image {name}.") + 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 @@ -201,7 +232,8 @@ def imageview_id(self): The image ID. """ imageview_names = self.session.get_value( - "imageViewConfigStore.imageNames") + "imageViewConfigStore.imageNames" + ) return imageview_names.index(self.file_name) @property @@ -229,6 +261,7 @@ def layer_list(self): list of :obj:`carta.colorblending.Layer` A list of Layer objects. """ + def count_layers(): idx = 0 while True: @@ -238,6 +271,7 @@ def count_layers(): except CartaActionFailed: break return idx + return [Layer(self, i) for i in range(count_layers())] def add_layer(self, image): @@ -411,4 +445,5 @@ def set_vectoroverlay_visible(self, state): def close(self): """Close this color blending object.""" self.session.call_action( - "imageViewConfigStore.removeColorBlending", self._frame) + "imageViewConfigStore.removeColorBlending", self._frame + ) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 98d53ec..dbb01c8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -230,6 +230,23 @@ Properties which affect the whole session can be set through the session object: 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 @@ -243,6 +260,10 @@ Create a color blending object from a list of images. # 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() From 266f1c1f446555652769b60a0716b6b1385398a0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 10 Sep 2025 16:15:58 +0800 Subject: [PATCH 6/7] Add tests for ColorBlending and Layer classes --- tests/test_colorblending.py | 320 ++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/test_colorblending.py diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py new file mode 100644 index 0000000..82efff3 --- /dev/null +++ b/tests/test_colorblending.py @@ -0,0 +1,320 @@ +import pytest + +from carta.colorblending import ColorBlending, Layer +from carta.image import Image +from carta.util import Macro, CartaActionFailed, CartaValidationFailed +from carta.constants import Colormap as CM, ColormapSet as CMS + + +# 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): + img0 = Image(session, 100) + img1 = Image(session, 200) + + mocker.patch.object(session, "call_action") + mocker.patch.object(type(img1), "file_name", new_callable=mocker.PropertyMock, return_value="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" From 606c9f3a1030473f80e5d15336b6cc4d6b60ad24 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 11 Sep 2025 14:00:57 +0800 Subject: [PATCH 7/7] Improve mock consistency --- tests/test_colorblending.py | 102 ++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 82efff3..9cb78c1 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -1,10 +1,10 @@ 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 Macro, CartaActionFailed, CartaValidationFailed -from carta.constants import Colormap as CM, ColormapSet as CMS - +from carta.util import CartaActionFailed, CartaValidationFailed, Macro # FIXTURES @@ -126,7 +126,9 @@ def test_colorblending_file_name(colorblending, cb_get_value): cb_get_value.assert_called_with("filename") -def test_colorblending_imageview_id(session, colorblending, session_get_value, cb_property): +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 @@ -138,7 +140,9 @@ def test_colorblending_alpha(colorblending, cb_get_value): cb_get_value.assert_called_with("alpha") -def test_colorblending_make_active(session, colorblending, cb_property, session_call_action): +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) @@ -154,7 +158,11 @@ def test_colorblending_layer_list_derived(session, mocker): # 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 + 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] @@ -166,15 +174,21 @@ def test_colorblending_add_layer(colorblending, cb_call_action, image): @pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) -def test_colorblending_delete_layer(colorblending, cb_call_action, idx, expected_param): +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): +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) + cb_call_action.assert_called_with( + "setSelectedFrame", expected_param, image._frame + ) def test_colorblending_reorder_layers(session, colorblending, mocker): @@ -184,7 +198,11 @@ 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.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") @@ -241,11 +259,23 @@ def test_colorblending_set_alpha_invalid(colorblending, vals): "getter,method,action,state", [ ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), - ("contourVisible", "set_contour_visible", "toggleContourVisible", True), - ("vectorOverlayVisible", "set_vectoroverlay_visible", "toggleVectorOverlayVisible", False), + ( + "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): +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) @@ -256,11 +286,23 @@ def test_colorblending_toggle_visibility_when_needed(colorblending, cb_get_value "getter,method,action,state", [ ("rasterVisible", "set_raster_visible", "toggleRasterVisible", True), - ("contourVisible", "set_contour_visible", "toggleContourVisible", False), - ("vectorOverlayVisible", "set_vectoroverlay_visible", "toggleVectorOverlayVisible", 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): +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) @@ -293,27 +335,41 @@ def test_colorblending_from_images_success(session, mocker): 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) + 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") + session.call_action.assert_called_with( + "imageViewConfigStore.createColorBlending", return_path="id" + ) -def test_colorblending_from_images_alignment_failure(session, mocker): +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") - mocker.patch.object(type(img1), "file_name", new_callable=mocker.PropertyMock, return_value="bad.fits") + 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) + 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") + 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()