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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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() From 34bf41b18aeb81986a6c4c1c2f79f441d6e82a9f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 15:29:48 +0800 Subject: [PATCH 8/9] Fix duplicate imports and docstring formatting in colorblending and image modules --- carta/colorblending.py | 2 +- carta/image.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index e9cfd45..558e829 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -14,7 +14,7 @@ class Layer(BasePathMixin): """This object represents a single layer in a color blending object. - ` + Parameters ---------- colorblending : :obj:`carta.colorblending.ColorBlending` diff --git a/carta/image.py b/carta/image.py index e111cee..12fef67 100644 --- a/carta/image.py +++ b/carta/image.py @@ -5,15 +5,12 @@ 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 .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 .contours import Contours from .vector_overlay import VectorOverlay from .wcs_overlay import ImageWCSOverlay from .region import RegionSet From 5d4fca4e01fe17c16323612f288f130eeb0c598d Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 15:30:29 +0800 Subject: [PATCH 9/9] Add colorblending module to API documentation --- docs/source/carta.rst | 8 ++++++++ 1 file changed, 8 insertions(+) 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 ----------------------