From 6087d69d8bfc818630fd73499260f52769fb8b05 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 30 Apr 2025 16:38:15 +0800 Subject: [PATCH 01/35] 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 02/35] 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 03/35] 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 04/35] 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 05/35] 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 06/35] 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 07/35] 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 08/35] 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 09/35] 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 ---------------------- From 032949d0d416e6d28debd8196069b6bc1484e5e8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 16:08:57 +0800 Subject: [PATCH 10/35] Refactor layers property to use Layer.from_list method --- carta/colorblending.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 558e829..58e3020 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -272,7 +272,7 @@ def count_layers(): break return idx - return [Layer(self, i) for i in range(count_layers())] + return Layer.from_list(self, list(range(count_layers()))) def add_layer(self, image): """Add a new layer to the color blending. From 2e0abceb1c2363bbef63e6c7b90e3b62f9d4222f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 10 Apr 2026 16:09:56 +0800 Subject: [PATCH 11/35] Fix indentation in Layer class docstring --- carta/colorblending.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 58e3020..ae2eaa3 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -15,21 +15,21 @@ 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): From 5c52466a0dd01e646b0e2a4df872cb7ba5e4e7fd Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:30:05 +0800 Subject: [PATCH 12/35] Rename reorder_layers to set_layer_sequence and add support for layer subsetting and alpha preservation --- carta/colorblending.py | 63 ++++++++++++++++----- docs/source/quickstart.rst | 7 ++- tests/test_colorblending.py | 106 +++++++++++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 18 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index ae2eaa3..6bdb46b 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -310,26 +310,63 @@ def set_layer(self, image, layer_index): """ 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. + @validate(IterableOf(Number(0, None), min_size=1)) + def set_layer_sequence(self, layer_indices): + """Set which layers are included in the color blending and in what + order. Parameters ---------- - order_list : list of int - The list of layer indices in the desired order. The list must not - contain the base layer (index = 0). + layer_indices : list of int + The layer indices to keep, in the desired order. The first index + must be the base layer (index = 0). Existing alpha values are + preserved. """ - layers = self.layer_list() - image_ids = [layer.image_id for layer in layers] + current_layers = self.layer_list() + max_current_layer_index = len(current_layers) - 1 + if any( + layer_index > max_current_layer_index + for layer_index in layer_indices + ): + raise ValueError( + "layer_indices contains a layer index which does not exist." + ) + + if layer_indices[0] != 0: + raise ValueError( + "layer_indices must start with the base layer index 0." + ) + + if 0 in layer_indices[1:]: + raise ValueError( + "layer_indices must contain the base layer index 0 only once, " + "as the first index." + ) + + current_layer_indices = list(range(len(current_layers))) + if layer_indices == current_layer_indices: + return + + current_alpha_values = self.alpha + target_layer_states = [ + ( + Image(self.session, current_layers[layer_index].image_id), + current_alpha_values[layer_index], + ) + for layer_index in layer_indices[1:] + ] + # Delete all layers except the base layer - for _ in layers[1:]: - # Delete the first layer - # The previous second layer becomes the first layer + for _ in current_layers[1:]: + # Delete layer at index 1 (the first non-base layer); + # after deletion, the previous layer at index 2 shifts to index 1 self.delete_layer(1) - for idx in order_list: - image = Image(self.session, image_ids[idx]) + + for target_layer_index, (image, alpha) in enumerate( + target_layer_states, start=1 + ): self.add_layer(image) + Layer(self, target_layer_index).set_alpha(alpha) @validate(Coordinate(), Coordinate()) def set_center(self, x, y): diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index dbb01c8..6b6b1a8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -286,10 +286,11 @@ Manipulate properties of the color blending object and the underlying images. # 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, + # Set which layers to keep, and in what order + # The first layer index must be the base layer (id = 0) + # Since the base layer cannot be reordered, # the layers will be reordered as [img0, img2, img1] - cb.reorder_layers([2, 1]) + cb.set_layer_sequence([0, 2, 1]) # Remove the last layer (id = 2) cb.delete_layer(2) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 9cb78c1..a39a4f9 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -191,7 +191,7 @@ def test_colorblending_set_layer( ) -def test_colorblending_reorder_layers(session, colorblending, mocker): +def test_colorblending_set_layer_sequence(session, colorblending, mocker): # Prepare three existing layers with image_ids 10, 20, 30 class _L: def __init__(self, lid, iid): @@ -203,15 +203,117 @@ def __init__(self, lid, iid): "layer_list", return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + return_value=[1.0, 0.2, 0.8], + ) del_layer = mocker.patch.object(colorblending, "delete_layer") add_layer = mocker.patch.object(colorblending, "add_layer") + set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.reorder_layers([2, 1]) + colorblending.set_layer_sequence([0, 2, 1]) # Deletes all non-base layers (twice) then adds layers in specified order assert del_layer.call_count == 2 add_args = [call.args[0] for call in add_layer.call_args_list] assert [img.image_id for img in add_args] == [30, 20] + assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] + + +def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], + ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + return_value=[1.0, 0.2, 0.8, 0.4], + ) + del_layer = mocker.patch.object(colorblending, "delete_layer") + add_layer = mocker.patch.object(colorblending, "add_layer") + set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) + + colorblending.set_layer_sequence([0, 3, 1]) + + assert del_layer.call_count == 3 + assert [call.args[0].image_id for call in add_layer.call_args_list] == [40, 20] + assert [call.args[1] for call in set_alpha.call_args_list] == [0.4, 0.2] + + +def test_colorblending_set_layer_sequence_rejects_missing_layer_index( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], + ) + + with pytest.raises( + ValueError, + match="layer_indices contains a layer index which does not exist.", + ): + colorblending.set_layer_sequence([0, 4, 1]) + + +def test_colorblending_set_layer_sequence_requires_base_layer_first( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match="layer_indices must start with the base layer index 0.", + ): + colorblending.set_layer_sequence([2, 1]) + + +def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match=( + "layer_indices must contain the base layer index 0 only once, " + "as the first index." + ), + ): + colorblending.set_layer_sequence([0, 2, 0]) def test_colorblending_set_center(colorblending, mocker): From 06fff60193e12dcb36e42e777bbd38a3fdf393f8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:43:29 +0800 Subject: [PATCH 13/35] Simplify layer_list implementation by using frames.length instead of iterative probing --- carta/colorblending.py | 14 ++------------ tests/test_colorblending.py | 9 +++------ 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 6bdb46b..e7e58bd 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -261,18 +261,8 @@ def layer_list(self): 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.from_list(self, list(range(count_layers()))) + layer_count = self.get_value("frames.length") + return Layer.from_list(self, list(range(layer_count))) def add_layer(self, image): """Add a new layer to the color blending. diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index a39a4f9..44e3bdf 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -156,16 +156,13 @@ def test_colorblending_layer_list_derived(session, mocker): cb._base_path = f"imageViewConfigStore.colorBlendingImages[{cb.image_id}]" cb._frame = Macro("", cb._base_path) - # Simulate two layers and then failure for third + # Simulate two layers from the frontend's computed frames array length. gv = mocker.patch.object(cb, "get_value") - gv.side_effect = [ - 1, - 2, - CartaActionFailed("stop"), - ] # fileIds for idx 0,1 then fail + gv.return_value = 2 layers = cb.layer_list() assert [ly.layer_id for ly in layers] == [0, 1] + gv.assert_called_once_with("frames.length") def test_colorblending_add_layer(colorblending, cb_call_action, image): From b7af8dc4fcd4a124dbbffaac1adff720bac85695 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:49:54 +0800 Subject: [PATCH 14/35] Add explicit validation to prevent deletion of base layer in ColorBlending --- carta/colorblending.py | 4 +++- tests/test_colorblending.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index e7e58bd..b18cf40 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -274,7 +274,7 @@ def add_layer(self, image): """ self.call_action("addSelectedFrame", image._frame) - @validate(Number(1, None)) + @validate(Number(0, None)) def delete_layer(self, layer_index): """Delete a layer from the color blending. @@ -284,6 +284,8 @@ def delete_layer(self, layer_index): The layer index. The base layer (layer_index = 0) cannot be deleted. """ + if layer_index == 0: + raise ValueError("The base layer cannot be deleted.") self.call_action("deleteSelectedFrame", layer_index - 1) @validate(InstanceOf(Image), Number(1, None)) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 44e3bdf..be893ef 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -178,6 +178,15 @@ def test_colorblending_delete_layer( cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) +def test_colorblending_delete_layer_rejects_base_layer( + colorblending, cb_call_action +): + with pytest.raises(ValueError, match="The base layer cannot be deleted."): + colorblending.delete_layer(0) + + cb_call_action.assert_not_called() + + @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) def test_colorblending_set_layer( colorblending, cb_call_action, image, idx, expected_param From fbcf72f3e772a6c3ea7ea3dea4423c39d0d5d4de Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 14:55:17 +0800 Subject: [PATCH 15/35] Add validation to prevent alpha list length mismatch in set_alpha method --- carta/colorblending.py | 5 +++++ tests/test_colorblending.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index b18cf40..243440e 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -429,6 +429,11 @@ def set_alpha(self, alpha_list): The alpha values. """ layer_list = self.layer_list() + if len(alpha_list) != len(layer_list): + raise ValueError( + f"alpha_list length ({len(alpha_list)}) does not match " + f"the number of layers ({len(layer_list)})." + ) for alpha, layer in zip(alpha_list, layer_list): layer.set_alpha(alpha) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index be893ef..a257c98 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -363,6 +363,16 @@ def test_colorblending_set_alpha_invalid(colorblending, vals): colorblending.set_alpha(vals) +@pytest.mark.parametrize("vals", [[0.5], [0.1, 0.2, 0.3]]) +def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): + ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) + mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + + with pytest.raises(ValueError, match="does not match"): + colorblending.set_alpha(vals) + + @pytest.mark.parametrize( "getter,method,action,state", [ From 6596ab364d52976495f0d158a0a34e24c024871f Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 18:22:09 +0800 Subject: [PATCH 16/35] Refactor ColorBlending to use store_id instead of image_id and add from_imageview_id class method --- carta/colorblending.py | 83 +++++++++++++++++++++------------ carta/constants.py | 7 +++ tests/test_colorblending.py | 93 ++++++++++++++++++++++++++++++------- 3 files changed, 136 insertions(+), 47 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 243440e..978256a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,6 +1,6 @@ -from .constants import Colormap, ColormapSet +from .constants import Colormap, ColormapSet, ImageType from .image import Image -from .util import BasePathMixin, CartaActionFailed, Macro, cached +from .util import BasePathMixin, CartaActionFailed, Macro from .validation import ( Boolean, Constant, @@ -19,14 +19,14 @@ class Layer(BasePathMixin): ---------- colorblending : :obj:`carta.colorblending.ColorBlending` The color blending object. - layer_id : int + layer_id : integer The layer ID. Attributes ---------- colorblending : :obj:`carta.colorblending.ColorBlending` The color blending object. - layer_id : int + layer_id : integer The layer ID. session : :obj:`carta.session.Session` The session object associated with this layer. @@ -49,7 +49,7 @@ def from_list(cls, colorblending, layer_ids): ---------- colorblending : :obj:`carta.colorblending.ColorBlending` The color blending object. - layer_ids : list of int + layer_ids : list of integer The layer IDs. Returns @@ -62,16 +62,15 @@ def from_list(cls, colorblending, 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_imageview_id = self.colorblending.imageview_id cb_name = self.colorblending.file_name repr_content = [ - f"{session_id}:{cb_id}:{cb_name}", + f"{session_id}:{cb_imageview_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. @@ -83,13 +82,12 @@ def file_name(self): return self.get_value("frameInfo.fileInfo.name") @property - @cached def image_id(self): """The ID of the image. Returns ------- - int + integer The image ID. """ return self.get_value("frameInfo.fileId") @@ -127,26 +125,51 @@ class ColorBlending(BasePathMixin): ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - image_id : int - The image ID. + store_id : integer + The color blending store ID of the color blending image. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - image_id : int - The image ID. + store_id : integer + The color blending store ID of the color blending image. """ - def __init__(self, session, image_id): + def __init__(self, session, store_id): self.session = session - self.image_id = image_id + self.store_id = store_id path = "imageViewConfigStore.colorBlendingImages" - self._base_path = f"{path}[{self.image_id}]" + self._base_path = f"{path}[{self.store_id}]" self._frame = Macro("", self._base_path) - self.base_frame = Image(self.session, self.layer_list()[0].image_id) + @classmethod + def from_imageview_id(cls, session, imageview_id): + """Create a color blending object from an image view ID. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object. + imageview_id : integer + The image view ID, the index of the image within the list of + currently open images, of the color blending image. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + A new color blending object. + """ + # Find the store ID for the given image view ID + path = f"imageViewConfigStore.imageList[{imageview_id}]" + image_type = session.get_value(f"{path}.type") + if image_type != ImageType.COLOR_BLENDING: + raise ValueError( + "imageview_id does not refer to a color blending image." + ) + store_id = session.get_value(f"{path}.store.id") + return cls(session, store_id) @classmethod def from_images(cls, session, images): @@ -178,8 +201,8 @@ def from_images(cls, session, images): ) command = "imageViewConfigStore.createColorBlending" - image_id = session.call_action(command, return_path="id") - return cls(session, image_id) + store_id = session.call_action(command, return_path="id") + return cls(session, store_id) @classmethod def from_files(cls, session, files, append=False): @@ -210,7 +233,10 @@ def __repr__(self): return f"{session_id}:{self.imageview_id}:{self.file_name}" @property - @cached + def _base_frame(self): + return Image(self.session, self.get_value("frames[0].id")) + + @property def file_name(self): """The name of the image. @@ -222,14 +248,13 @@ def file_name(self): return self.get_value("filename") @property - @cached def imageview_id(self): - """The ID of the image in imageView. + """The image view ID of the color blending image. Returns ------- integer - The image ID. + The image view ID. """ imageview_names = self.session.get_value( "imageViewConfigStore.imageNames" @@ -280,7 +305,7 @@ def delete_layer(self, layer_index): Parameters ---------- - layer_index : int + layer_index : integer The layer index. The base layer (layer_index = 0) cannot be deleted. """ @@ -296,7 +321,7 @@ def set_layer(self, image, layer_index): ---------- image : :obj:`carta.image.Image` The image to set. - layer_index : int + layer_index : integer The layer index. The base layer (layer_index = 0) cannot be set. """ @@ -309,7 +334,7 @@ def set_layer_sequence(self, layer_indices): Parameters ---------- - layer_indices : list of int + layer_indices : list of integer The layer indices to keep, in the desired order. The first index must be the base layer (index = 0). Existing alpha values are preserved. @@ -388,7 +413,7 @@ def set_center(self, x, y): information, or if world coordinates do not match the session-wide number formats. """ - self.base_frame.set_center(x, y) + self._base_frame.set_center(x, y) @validate(Number(), Boolean()) def set_zoom_level(self, zoom, absolute=True): @@ -404,7 +429,7 @@ def set_zoom_level(self, zoom, absolute=True): 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) + self._base_frame.set_zoom_level(zoom, absolute) @validate(Constant(ColormapSet)) def set_colormap_set(self, colormap_set): diff --git a/carta/constants.py b/carta/constants.py index dd3576d..b862cc6 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -30,6 +30,13 @@ class ColormapSet(StrEnum): Rainbow = "Rainbow" +class ImageType(IntEnum): + """Image view item types, corresponding to the frontend ImageType enum.""" + FRAME = 0 + COLOR_BLENDING = 1 + PV_PREVIEW = 2 + + Scaling = IntEnum('Scaling', ('LINEAR', 'LOG', 'SQRT', 'SQUARE', 'POWER', 'GAMMA'), start=0) Scaling.__doc__ = """Colormap scaling types.""" diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index a257c98..1e91e9d 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -3,6 +3,7 @@ from carta.colorblending import ColorBlending, Layer from carta.constants import Colormap as CM from carta.constants import ColormapSet as CMS +from carta.constants import ImageType from carta.image import Image from carta.util import CartaActionFailed, CartaValidationFailed, Macro @@ -10,13 +11,7 @@ @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)]) +def colorblending(session): return ColorBlending(session, 0) @@ -115,6 +110,18 @@ def test_layer_set_colormap(layer, layer_call_action, invert): # TESTS — ColorBlending basics +def test_colorblending_init(session): + colorblending = ColorBlending(session, 3) + assert colorblending.store_id == 3 + assert ( + colorblending._base_path + == "imageViewConfigStore.colorBlendingImages[3]" + ) + assert colorblending._frame == Macro( + "", "imageViewConfigStore.colorBlendingImages[3]" + ) + + def test_colorblending_repr(session, colorblending, cb_property): cb_property("imageview_id", 3) cb_property("file_name", "blend.fits") @@ -140,6 +147,16 @@ def test_colorblending_alpha(colorblending, cb_get_value): cb_get_value.assert_called_with("alpha") +def test_colorblending_base_frame(colorblending, cb_get_value): + cb_get_value.return_value = 42 + base_frame = colorblending._base_frame + + cb_get_value.assert_called_once_with("frames[0].id") + assert isinstance(base_frame, Image) + assert base_frame.session is colorblending.session + assert base_frame.image_id == 42 + + def test_colorblending_make_active( session, colorblending, cb_property, session_call_action ): @@ -149,12 +166,7 @@ def test_colorblending_make_active( 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) + cb = ColorBlending(session, 3) # Simulate two layers from the frontend's computed frames array length. gv = mocker.patch.object(cb, "get_value") @@ -323,16 +335,28 @@ def __init__(self, lid, iid): def test_colorblending_set_center(colorblending, mocker): - set_center = mocker.patch.object(colorblending.base_frame, "set_center") + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + colorblending.set_center(1, 2) - set_center.assert_called_with(1, 2) + base_frame.set_center.assert_called_once_with(1, 2) @pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): - set_zoom = mocker.patch.object(colorblending.base_frame, "set_zoom_level") + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + colorblending.set_zoom_level(zoom, absolute) - set_zoom.assert_called_with(zoom, absolute) + base_frame.set_zoom_level.assert_called_once_with(zoom, absolute) def test_colorblending_set_colormap_set(colorblending, cb_call_action, mocker): @@ -437,6 +461,38 @@ def test_colorblending_close(session, colorblending, session_call_action): # CREATION HELPERS +def test_colorblending_from_imageview_id(session, session_get_value, mocker): + session_get_value.side_effect = [ImageType.COLOR_BLENDING, 17] + init = mocker.patch.object(ColorBlending, "__init__", return_value=None) + + cb = ColorBlending.from_imageview_id(session, 5) + + assert isinstance(cb, ColorBlending) + assert [call.args for call in session_get_value.call_args_list] == [ + ("imageViewConfigStore.imageList[5].type",), + ("imageViewConfigStore.imageList[5].store.id",), + ] + init.assert_called_once_with(session, 17) + + +def test_colorblending_from_imageview_id_rejects_non_color_blending( + session, session_get_value, mocker +): + session_get_value.return_value = ImageType.FRAME + init = mocker.patch.object(ColorBlending, "__init__", return_value=None) + + with pytest.raises( + ValueError, + match="imageview_id does not refer to a color blending image.", + ): + ColorBlending.from_imageview_id(session, 5) + + session_get_value.assert_called_once_with( + "imageViewConfigStore.imageList[5].type" + ) + init.assert_not_called() + + def test_colorblending_from_images_success(session, mocker): # Prepare two images to blend img0 = Image(session, 100) @@ -450,7 +506,7 @@ def test_colorblending_from_images_success(session, mocker): session.call_action.side_effect = [None, 123] # Avoid __init__ side effects; just ensure returned instance - mocker.patch.object(ColorBlending, "__init__", return_value=None) + init = 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( @@ -460,6 +516,7 @@ def test_colorblending_from_images_success(session, mocker): session.call_action.assert_called_with( "imageViewConfigStore.createColorBlending", return_path="id" ) + init.assert_called_once_with(session, 123) def test_colorblending_from_images_alignment_failure( From 9a20cfaabcad7002acbb9f1601c2575a99ab5dd3 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 18:31:38 +0800 Subject: [PATCH 17/35] Add color_blending_list method to Session class for retrieving open color blending objects --- carta/session.py | 14 ++++++++++++++ tests/test_session.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/carta/session.py b/carta/session.py index fc0ddc8..e924ccd 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,6 +10,7 @@ import posixpath from .image import Image +from .colorblending import ColorBlending from .constants import PanelMode, GridMode, ComplexComponent, Polarization from .backend import Backend from .protocol import Protocol @@ -520,6 +521,19 @@ def image_list(self): """ return Image.from_list(self, self.get_value("frameNames")) + def color_blending_list(self): + """Return the list of currently open color blending objects. + + Returns + ------- + list of :obj:`carta.colorblending.ColorBlending` objects + The list of color blending objects open in this session. + """ + path = "imageViewConfigStore.colorBlendingImages" + length = self.get_value(f"{path}.length") + store_ids = [self.get_value(f"{path}[{idx}].id") for idx in range(length)] + return [ColorBlending(self, store_id) for store_id in store_ids] + def active_frame(self): """Return the currently active image. diff --git a/tests/test_session.py b/tests/test_session.py index cf79849..108a697 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,6 +1,7 @@ import pytest from carta.image import Image +from carta.colorblending import ColorBlending from carta.util import Macro from carta.constants import ComplexComponent as CC, Polarization as Pol @@ -69,6 +70,29 @@ def test_cd(session, method, call_action): session.cd("original/path") call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") + +def test_color_blending_list(session, get_value): + get_value.side_effect = [2, 3, 8] + + color_blendings = session.color_blending_list() + + assert len(color_blendings) == 2 + assert all(isinstance(cb, ColorBlending) for cb in color_blendings) + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages.length") + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[0].id") + get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[1].id") + assert [cb.session for cb in color_blendings] == [session, session] + assert [cb.store_id for cb in color_blendings] == [3, 8] + + +def test_color_blending_list_empty(session, get_value): + get_value.return_value = 0 + + assert session.color_blending_list() == [] + get_value.assert_called_once_with( + "imageViewConfigStore.colorBlendingImages.length" + ) + # OPENING IMAGES From d9bcab4925b6e6017a0a17718d1e34c5e047beb8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:02:11 +0800 Subject: [PATCH 18/35] Update quickstart documentation to clarify ColorBlending layer terminology and usage patterns --- docs/source/quickstart.rst | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 6b6b1a8..538ca02 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -239,7 +239,7 @@ Create a color blending object from a list of files. # 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. + # Note: The base layer (index = 0) cannot be deleted or moved. files = [ "data/hdf5/first_file.hdf5", "data/fits/second_file.fits", @@ -257,9 +257,27 @@ Create a color blending object from a list of images. # 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. + # Note: The base layer (index = 0) cannot be deleted or moved. cb = ColorBlending.from_images(session, [img0, img1, img2]) +To work with color blending images that are already open in a session, use +the session helper. + +.. code-block:: python + + # Get all open color blending objects in this session + color_blendings = session.color_blending_list() + cb = color_blendings[0] + + # Or get a color blending object by its image view index + cb = ColorBlending.from_imageview_id(session, 3) + +.. note:: + The ``ColorBlending`` constructor takes the internal color blending store ID, + not the image view index. Use ``ColorBlending.from_files``, + ``ColorBlending.from_images``, ``ColorBlending.from_imageview_id`` or + ``session.color_blending_list`` in scripts. + Manipulate properties of the color blending object and the underlying images. .. code-block:: python @@ -287,12 +305,12 @@ Manipulate properties of the color blending object and the underlying images. cb.set_alpha([0.7, 0.8, 0.9]) # Set which layers to keep, and in what order - # The first layer index must be the base layer (id = 0) - # Since the base layer cannot be reordered, + # The first layer index must be the base layer (index = 0) + # Since the base layer cannot be moved, # the layers will be reordered as [img0, img2, img1] cb.set_layer_sequence([0, 2, 1]) - # Remove the last layer (id = 2) + # Remove the last layer (index = 2) cb.delete_layer(2) # Add a new layer @@ -316,7 +334,8 @@ Manipulate properties of the color blending object and the underlying images. 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. + If you need to change the layer order involving the base layer (index = 0), + close the current color blending object and create a new one. Saving or displaying an image ----------------------------- From a34e64649b9376c9b20e5ca8883a9e5c0c941a10 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:12:05 +0800 Subject: [PATCH 19/35] Drop support for carta version < 5.0 for make_active --- carta/image.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/carta/image.py b/carta/image.py index 12fef67..6292415 100644 --- a/carta/image.py +++ b/carta/image.py @@ -255,12 +255,7 @@ def polarizations(self): def make_active(self): """Make this the active image.""" - 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) + self.session.call_action("setActiveImageByFileId", self.image_id) def make_spatial_reference(self): """Make this image the spatial reference.""" From 4ce4429e665ac9abcd582de25f318250741cef6e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:14:52 +0800 Subject: [PATCH 20/35] Update documentation references to use new wcs_overlay module paths for coordinate system and number format methods --- carta/colorblending.py | 5 +++-- carta/image.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 978256a..8b3140a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -391,8 +391,9 @@ def set_center(self, x, y): 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`. + changed using + :obj:`carta.wcs_overlay.Global.set_coordinate_system` and + :obj:`carta.wcs_overlay.Numbers.set_format`. Coordinates must either both be image coordinates or match the current number formats. Numbers are interpreted as image coordinates, and diff --git a/carta/image.py b/carta/image.py index 6292415..95a9e9f 100644 --- a/carta/image.py +++ b/carta/image.py @@ -361,7 +361,7 @@ def valid_wcs(self): def set_center(self, x, y): """Set the center position, in image or world coordinates. - World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.session.set_coordinate_system` and :obj:`set_custom_number_format`. + World coordinates are interpreted according to the session's globally set coordinate system and any custom number formats. These can be changed using :obj:`carta.wcs_overlay.Global.set_coordinate_system` and :obj:`carta.wcs_overlay.Numbers.set_format`. Coordinates must either both be image coordinates or match the current number formats. Numbers are interpreted as image coordinates, and numeric strings with no units are interpreted as degrees. From 25e0baa117f7658d253c1df2b56d4d931859b550 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:16:04 +0800 Subject: [PATCH 21/35] Add validation test for invalid colormap name in Layer.set_colormap method --- tests/test_colorblending.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 1e91e9d..04ebc71 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -107,6 +107,13 @@ def test_layer_set_colormap(layer, layer_call_action, invert): layer_call_action.assert_any_call("renderConfig.setInverted", invert) +def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): + with pytest.raises(CartaValidationFailed): + layer.set_colormap("not-a-colormap") + + layer_call_action.assert_not_called() + + # TESTS — ColorBlending basics From cb22ed59f783fb3db10611fc557bd2f1fb728fb7 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:17:40 +0800 Subject: [PATCH 22/35] Fix indentation in Layer.from_layer_ids docstring return section --- carta/colorblending.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8b3140a..15566ac 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -55,7 +55,7 @@ def from_list(cls, colorblending, layer_ids): Returns ------- list of :obj:`carta.colorblending.Layer` - A list of new Layer objects. + A list of new Layer objects. """ return [cls(colorblending, layer_id) for layer_id in layer_ids] From 91e952dace306fd004e3446a340330764b53d8ce Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 20:40:54 +0800 Subject: [PATCH 23/35] Add validation to prevent exceeding maximum initial layer count in ColorBlending initialization --- carta/colorblending.py | 21 +++++++++++++++++++ tests/test_colorblending.py | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index 15566ac..9c702aa 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -136,6 +136,9 @@ class ColorBlending(BasePathMixin): The color blending store ID of the color blending image. """ + # Mirrors ColorBlendingStore.DEFAULT_LAYER_LIMIT in carta-frontend. + MAX_INITIAL_LAYERS = 10 + def __init__(self, session, store_id): self.session = session self.store_id = store_id @@ -144,6 +147,15 @@ def __init__(self, session, store_id): self._base_path = f"{path}[{self.store_id}]" self._frame = Macro("", self._base_path) + @classmethod + def _validate_initial_layer_count(cls, layer_count): + if layer_count > cls.MAX_INITIAL_LAYERS: + raise ValueError( + "Color blending initialization supports at most " + f"{cls.MAX_INITIAL_LAYERS} images (the base layer plus " + f"{cls.MAX_INITIAL_LAYERS - 1} matched images)." + ) + @classmethod def from_imageview_id(cls, session, imageview_id): """Create a color blending object from an image view ID. @@ -186,7 +198,15 @@ def from_images(cls, session, images): ------- :obj:`carta.colorblending.ColorBlending` A new color blending object. + + Raises + ------ + ValueError + If more images are provided than the frontend can include when + initializing the color blending layers. """ + cls._validate_initial_layer_count(len(images)) + # Set the first image as the spatial reference session.call_action("setSpatialReference", images[0]._frame, False) # Align the other images to the spatial reference @@ -224,6 +244,7 @@ def from_files(cls, session, files, append=False): :obj:`carta.colorblending.ColorBlending` A new color blending object. """ + cls._validate_initial_layer_count(len(files)) images = session.open_images(files, append=append) return cls.from_images(session, images) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 04ebc71..b0fd26d 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -543,6 +543,27 @@ def test_colorblending_from_images_alignment_failure( ) +def test_colorblending_from_images_rejects_more_than_initial_layer_limit( + session, mocker +): + images = [ + Image(session, image_id) + for image_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) + ] + session_call_action = mocker.patch.object(session, "call_action") + + with pytest.raises( + ValueError, + match=( + "Color blending initialization supports at most 10 images " + r"\(the base layer plus 9 matched images\)." + ), + ): + ColorBlending.from_images(session, images) + + session_call_action.assert_not_called() + + def test_colorblending_from_files(session, mocker): mock_open_images = mocker.patch.object( session, @@ -556,3 +577,24 @@ def test_colorblending_from_files(session, mocker): mock_open_images.assert_called_with(["a.fits", "b.fits"], append=True) mock_from_images.assert_called() assert out == "CB" + + +def test_colorblending_from_files_rejects_more_than_initial_layer_limit( + session, mocker +): + files = [ + f"image-{file_id}.fits" + for file_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) + ] + mock_open_images = mocker.patch.object(session, "open_images") + + with pytest.raises( + ValueError, + match=( + "Color blending initialization supports at most 10 images " + r"\(the base layer plus 9 matched images\)." + ), + ): + ColorBlending.from_files(session, files) + + mock_open_images.assert_not_called() From 3f1c4fe04c0118a582cd3ca17d26a5ced5fb9a95 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:40:43 +0800 Subject: [PATCH 24/35] Rename set_vectoroverlay_visible method to set_vector_overlay_visible for consistency with naming conventions --- carta/colorblending.py | 2 +- tests/test_colorblending.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 9c702aa..9f0c886 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -511,7 +511,7 @@ def set_contour_visible(self, state): self.call_action("toggleContourVisible") @validate(Boolean()) - def set_vectoroverlay_visible(self, state): + def set_vector_overlay_visible(self, state): """Set the vector overlay visibility. Parameters diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index b0fd26d..9be2bc3 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -416,7 +416,7 @@ def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): ), ( "vectorOverlayVisible", - "set_vectoroverlay_visible", + "set_vector_overlay_visible", "toggleVectorOverlayVisible", False, ), @@ -443,7 +443,7 @@ def test_colorblending_toggle_visibility_when_needed( ), ( "vectorOverlayVisible", - "set_vectoroverlay_visible", + "set_vector_overlay_visible", "toggleVectorOverlayVisible", True, ), From d3377594f6c0bb39d27947d9952e2cbebc6a91b0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:42:20 +0800 Subject: [PATCH 25/35] Rename ColormapSet.Rainbow to RAINBOW for consistent uppercase enum naming convention --- carta/constants.py | 2 +- tests/test_colorblending.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index b862cc6..0cdf183 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -27,7 +27,7 @@ class ColormapSet(StrEnum): """Colormap sets for color blending.""" RGB = "RGB" CMY = "CMY" - Rainbow = "Rainbow" + RAINBOW = "Rainbow" class ImageType(IntEnum): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 9be2bc3..01958b2 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -372,8 +372,8 @@ def test_colorblending_set_colormap_set(colorblending, cb_call_action, mocker): 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) + 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) From 345c17dc4ae050d674c1fd527fe895a073a41bf8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:45:21 +0800 Subject: [PATCH 26/35] Add module docstring to colorblending.py describing color blending functionality --- carta/colorblending.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index 9f0c886..6d70048 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,3 +1,5 @@ +"""This module contains functionality for interacting with color blending images and their layers.""" + from .constants import Colormap, ColormapSet, ImageType from .image import Image from .util import BasePathMixin, CartaActionFailed, Macro From 2bf9d1f052183df481d91b1fee3424b666d55baa Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:50:14 +0800 Subject: [PATCH 27/35] Replace explicit type annotations with placeholder format strings in ColorBlending and Layer method docstrings --- carta/colorblending.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 6d70048..2a79752 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -100,7 +100,7 @@ def set_alpha(self, alpha): Parameters ---------- - alpha : float + alpha : {0} The alpha value. """ self.colorblending.call_action("setAlpha", self.layer_id, alpha) @@ -111,9 +111,9 @@ def set_colormap(self, colormap, invert=False): Parameters ---------- - colormap : :obj:`carta.constants.Colormap` + colormap : {0} The colormap. - invert : bool + invert : {1} Whether the colormap should be inverted. This is false by default. """ self.call_action("renderConfig.setColorMap", colormap) @@ -328,7 +328,7 @@ def delete_layer(self, layer_index): Parameters ---------- - layer_index : integer + layer_index : {0} The layer index. The base layer (layer_index = 0) cannot be deleted. """ @@ -342,9 +342,9 @@ def set_layer(self, image, layer_index): Parameters ---------- - image : :obj:`carta.image.Image` + image : {0} The image to set. - layer_index : integer + layer_index : {1} The layer index. The base layer (layer_index = 0) cannot be set. """ @@ -357,7 +357,7 @@ def set_layer_sequence(self, layer_indices): Parameters ---------- - layer_indices : list of integer + layer_indices : {0} The layer indices to keep, in the desired order. The first index must be the base layer (index = 0). Existing alpha values are preserved. @@ -461,7 +461,7 @@ def set_colormap_set(self, colormap_set): Parameters ---------- - colormap_set : :obj:`carta.constants.ColormapSet` + colormap_set : {0} The colormap set. """ self.call_action("applyColormapSet", colormap_set) @@ -474,7 +474,7 @@ def set_alpha(self, alpha_list): Parameters ---------- - alpha_list : list of float + alpha_list : {0} The alpha values. """ layer_list = self.layer_list() @@ -492,7 +492,7 @@ def set_raster_visible(self, state): Parameters ---------- - state : bool + state : {0} The desired visibility state. """ is_visible = self.get_value("rasterVisible") @@ -505,7 +505,7 @@ def set_contour_visible(self, state): Parameters ---------- - state : bool + state : {0} The desired visibility state. """ is_visible = self.get_value("contourVisible") @@ -518,7 +518,7 @@ def set_vector_overlay_visible(self, state): Parameters ---------- - state : bool + state : {0} The desired visibility state. """ is_visible = self.get_value("vectorOverlayVisible") From e16c9f43055eddaae9e8a1a82028b7670f6b6ecc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 21:52:46 +0800 Subject: [PATCH 28/35] Change periods to colons in ColorBlending section introductory sentences for consistency with documentation style --- docs/source/quickstart.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 538ca02..d6b6874 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -230,7 +230,7 @@ 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. +Create a color blending object from a list of files: .. code-block:: python @@ -247,7 +247,7 @@ Create a color blending object from a list of files. ] cb = ColorBlending.from_files(session, files, append=False) -Create a color blending object from a list of images. +Create a color blending object from a list of images: .. code-block:: python @@ -261,7 +261,7 @@ Create a color blending object from a list of images. cb = ColorBlending.from_images(session, [img0, img1, img2]) To work with color blending images that are already open in a session, use -the session helper. +the session helper: .. code-block:: python @@ -278,7 +278,7 @@ the session helper. ``ColorBlending.from_images``, ``ColorBlending.from_imageview_id`` or ``session.color_blending_list`` in scripts. -Manipulate properties of the color blending object and the underlying images. +Manipulate properties of the color blending object and the underlying images: .. code-block:: python From 2812ffaca3c6ca6620740d4cd1fb5ca4bca3de59 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 14 Apr 2026 22:17:56 +0800 Subject: [PATCH 29/35] Update test_make_active to use setActiveImageByFileId action instead of setActiveFrameById --- carta/image.py | 2 +- tests/test_image.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/carta/image.py b/carta/image.py index 95a9e9f..ea3945f 100644 --- a/carta/image.py +++ b/carta/image.py @@ -5,7 +5,7 @@ from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin, CartaActionFailed, Point as Pt +from .util import Macro, cached, BasePathMixin, 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 diff --git a/tests/test_image.py b/tests/test_image.py index 9a1c275..e8bf392 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -110,7 +110,7 @@ def test_simple_properties(image, property_name, expected_path, get_value): def test_make_active(image, session_call_action): image.make_active() - session_call_action.assert_called_with("setActiveFrameById", 0) + session_call_action.assert_called_with("setActiveImageByFileId", 0) @pytest.mark.parametrize("channel", [0, 10, 19]) From 509d4e2d2befb6ca0bfdb7335ea5b63bd35df90a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 12:37:00 +0800 Subject: [PATCH 30/35] Fix ColorBlending initialization to rebuild layers from requested images and update base path to colorBlendingImageMap --- carta/colorblending.py | 16 ++++++++++++-- tests/test_colorblending.py | 42 ++++++++++++++++++++++++++----------- tests/test_session.py | 4 ++++ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 2a79752..3832851 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -145,7 +145,7 @@ def __init__(self, session, store_id): self.session = session self.store_id = store_id - path = "imageViewConfigStore.colorBlendingImages" + path = "imageViewConfigStore.colorBlendingImageMap" self._base_path = f"{path}[{self.store_id}]" self._frame = Macro("", self._base_path) @@ -224,7 +224,19 @@ def from_images(cls, session, images): command = "imageViewConfigStore.createColorBlending" store_id = session.call_action(command, return_path="id") - return cls(session, store_id) + colorblending = cls(session, store_id) + + # The frontend initializes color blending from the current spatial + # reference's secondarySpatialImages, which can include frames matched + # before this helper was called. Rebuild the non-base layers so the + # blend contains exactly the images requested here without clearing the + # session-wide spatial matching state. + for _ in colorblending.layer_list()[1:]: + colorblending.delete_layer(1) + for image in images[1:]: + colorblending.add_layer(image) + + return colorblending @classmethod def from_files(cls, session, files, append=False): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 01958b2..313e952 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -122,10 +122,10 @@ def test_colorblending_init(session): assert colorblending.store_id == 3 assert ( colorblending._base_path - == "imageViewConfigStore.colorBlendingImages[3]" + == "imageViewConfigStore.colorBlendingImageMap[3]" ) assert colorblending._frame == Macro( - "", "imageViewConfigStore.colorBlendingImages[3]" + "", "imageViewConfigStore.colorBlendingImageMap[3]" ) @@ -468,18 +468,21 @@ def test_colorblending_close(session, colorblending, session_call_action): # CREATION HELPERS -def test_colorblending_from_imageview_id(session, session_get_value, mocker): +def test_colorblending_from_imageview_id(session, session_get_value): session_get_value.side_effect = [ImageType.COLOR_BLENDING, 17] - init = mocker.patch.object(ColorBlending, "__init__", return_value=None) cb = ColorBlending.from_imageview_id(session, 5) assert isinstance(cb, ColorBlending) + assert cb.store_id == 17 + assert ( + cb._base_path + == "imageViewConfigStore.colorBlendingImageMap[17]" + ) assert [call.args for call in session_get_value.call_args_list] == [ ("imageViewConfigStore.imageList[5].type",), ("imageViewConfigStore.imageList[5].store.id",), ] - init.assert_called_once_with(session, 17) def test_colorblending_from_imageview_id_rejects_non_color_blending( @@ -501,29 +504,44 @@ def test_colorblending_from_imageview_id_rejects_non_color_blending( def test_colorblending_from_images_success(session, mocker): - # Prepare two images to blend img0 = Image(session, 100) img1 = Image(session, 200) + img2 = Image(session, 300) - # setSpatialReference alignment returns True for img1 mocker.patch.object(session, "call_action") mocker.patch.object(img1, "call_action", return_value=True) + mocker.patch.object(img2, "call_action", return_value=True) + layer_list = mocker.patch.object( + ColorBlending, + "layer_list", + autospec=True, + return_value=[object(), object(), object(), object()], + ) + delete_layer = mocker.patch.object( + ColorBlending, "delete_layer", autospec=True + ) + add_layer = mocker.patch.object(ColorBlending, "add_layer", autospec=True) - # Create ID for new color blending session.call_action.side_effect = [None, 123] - # Avoid __init__ side effects; just ensure returned instance - init = mocker.patch.object(ColorBlending, "__init__", return_value=None) - cb = ColorBlending.from_images(session, [img0, img1]) + cb = ColorBlending.from_images(session, [img0, img1, img2]) + assert isinstance(cb, ColorBlending) + assert cb.store_id == 123 + assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" session.call_action.assert_any_call( "setSpatialReference", img0._frame, False ) img1.call_action.assert_called_with("setSpatialReference", img0._frame) + img2.call_action.assert_called_with("setSpatialReference", img0._frame) session.call_action.assert_called_with( "imageViewConfigStore.createColorBlending", return_path="id" ) - init.assert_called_once_with(session, 123) + layer_list.assert_called_once_with(cb) + delete_layer.assert_has_calls( + [mocker.call(cb, 1), mocker.call(cb, 1), mocker.call(cb, 1)] + ) + add_layer.assert_has_calls([mocker.call(cb, img1), mocker.call(cb, img2)]) def test_colorblending_from_images_alignment_failure( diff --git a/tests/test_session.py b/tests/test_session.py index 108a697..3fb07db 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -83,6 +83,10 @@ def test_color_blending_list(session, get_value): get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[1].id") assert [cb.session for cb in color_blendings] == [session, session] assert [cb.store_id for cb in color_blendings] == [3, 8] + assert [cb._base_path for cb in color_blendings] == [ + "imageViewConfigStore.colorBlendingImageMap[3]", + "imageViewConfigStore.colorBlendingImageMap[8]", + ] def test_color_blending_list_empty(session, get_value): From af48cf33cdf4e1896e44114bf98c1f06ef892ebc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 12:39:27 +0800 Subject: [PATCH 31/35] Add validation to reject duplicate layer indices in ColorBlending.set_layer_sequence method --- carta/colorblending.py | 5 +++++ tests/test_colorblending.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/carta/colorblending.py b/carta/colorblending.py index 3832851..8020bba 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -395,6 +395,11 @@ def set_layer_sequence(self, layer_indices): "as the first index." ) + if len(layer_indices) != len(set(layer_indices)): + raise ValueError( + "layer_indices must not contain duplicate layer indices." + ) + current_layer_indices = list(range(len(current_layers))) if layer_indices == current_layer_indices: return diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 313e952..ce76325 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -341,6 +341,27 @@ def __init__(self, lid, iid): colorblending.set_layer_sequence([0, 2, 0]) +def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( + session, colorblending, mocker +): + class _L: + def __init__(self, lid, iid): + self.layer_id = lid + self.image_id = iid + + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + + with pytest.raises( + ValueError, + match="layer_indices must not contain duplicate layer indices.", + ): + colorblending.set_layer_sequence([0, 1, 1]) + + def test_colorblending_set_center(colorblending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( From 2f7d76bc6ff72e25fc797e0a2d5847a39e82a49c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 12:46:09 +0800 Subject: [PATCH 32/35] Remove automatic inversion reset when applying colormap sets in ColorBlending.set_colormap_set method --- carta/colorblending.py | 2 -- tests/test_colorblending.py | 9 +-------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8020bba..8270cf9 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -482,8 +482,6 @@ def set_colormap_set(self, colormap_set): 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): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index ce76325..2c099d1 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -387,16 +387,9 @@ def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): base_frame.set_zoom_level.assert_called_once_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]) - +def test_colorblending_set_colormap_set(colorblending, cb_call_action): colorblending.set_colormap_set(CMS.RAINBOW) cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) - 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): From 56e019df8e68c15c7d287b9d8b3da4312d03e394 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 13:00:56 +0800 Subject: [PATCH 33/35] Fix imageview_id property to search imageList by store_id instead of matching file_name in imageNames array --- carta/colorblending.py | 16 +++++++++++++--- tests/test_colorblending.py | 16 ++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8270cf9..c1080fc 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -291,10 +291,20 @@ def imageview_id(self): integer The image view ID. """ - imageview_names = self.session.get_value( - "imageViewConfigStore.imageNames" + path = "imageViewConfigStore.imageList" + length = self.session.get_value(f"{path}.length") + for idx in range(length): + entry = f"{path}[{idx}]" + if ( + self.session.get_value(f"{entry}.type") + == ImageType.COLOR_BLENDING + and self.session.get_value(f"{entry}.store.id") + == self.store_id + ): + return idx + raise RuntimeError( + "Could not find this color blending image in the image list." ) - return imageview_names.index(self.file_name) @property def alpha(self): diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 2c099d1..c7a147a 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -140,13 +140,17 @@ 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 -): - cb_property("file_name", "imgC") - session_get_value.side_effect = [["imgA", "imgB", "imgC", "imgD"]] +def test_colorblending_imageview_id(session, colorblending, session_get_value): + # imageList has 3 entries; the color blending with store_id=0 is at index 2 + session_get_value.side_effect = [ + 3, # imageList.length + ImageType.FRAME, # [0].type — skip + ImageType.COLOR_BLENDING, # [1].type — match type… + 99, # [1].store.id — wrong store_id + ImageType.COLOR_BLENDING, # [2].type — match type… + 0, # [2].store.id — matches store_id=0 + ] assert colorblending.imageview_id == 2 - session_get_value.assert_called_with("imageViewConfigStore.imageNames") def test_colorblending_alpha(colorblending, cb_get_value): From 6e02ba28f32a5e6c568362f9885ca97b42d1f383 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 13:03:52 +0800 Subject: [PATCH 34/35] Convert layer_indices to list before comparing with current_layer_indices in set_layer_sequence method --- carta/colorblending.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index c1080fc..96191b1 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -411,7 +411,7 @@ def set_layer_sequence(self, layer_indices): ) current_layer_indices = list(range(len(current_layers))) - if layer_indices == current_layer_indices: + if list(layer_indices) == current_layer_indices: return current_alpha_values = self.alpha From 9ea1a6644fe40317719c1422b940f922a852f32c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 15 Apr 2026 13:31:56 +0800 Subject: [PATCH 35/35] Style check --- carta/colorblending.py | 11 +++++------ tests/test_colorblending.py | 12 ++++-------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 96191b1..c6d5bc0 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -295,12 +295,11 @@ def imageview_id(self): length = self.session.get_value(f"{path}.length") for idx in range(length): entry = f"{path}[{idx}]" - if ( - self.session.get_value(f"{entry}.type") - == ImageType.COLOR_BLENDING - and self.session.get_value(f"{entry}.store.id") - == self.store_id - ): + entry_type = self.session.get_value(f"{entry}.type") + if entry_type != ImageType.COLOR_BLENDING: + continue + entry_id = self.session.get_value(f"{entry}.store.id") + if entry_id == self.store_id: return idx raise RuntimeError( "Could not find this color blending image in the image list." diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index c7a147a..61f8ef3 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -120,10 +120,8 @@ def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): def test_colorblending_init(session): colorblending = ColorBlending(session, 3) assert colorblending.store_id == 3 - assert ( - colorblending._base_path - == "imageViewConfigStore.colorBlendingImageMap[3]" - ) + expected = "imageViewConfigStore.colorBlendingImageMap[3]" + assert colorblending._base_path == expected assert colorblending._frame == Macro( "", "imageViewConfigStore.colorBlendingImageMap[3]" ) @@ -493,10 +491,8 @@ def test_colorblending_from_imageview_id(session, session_get_value): assert isinstance(cb, ColorBlending) assert cb.store_id == 17 - assert ( - cb._base_path - == "imageViewConfigStore.colorBlendingImageMap[17]" - ) + expected = "imageViewConfigStore.colorBlendingImageMap[17]" + assert cb._base_path == expected assert [call.args for call in session_get_value.call_args_list] == [ ("imageViewConfigStore.imageList[5].type",), ("imageViewConfigStore.imageList[5].store.id",),