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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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",), From 50a21bc1b2085035594536fe117410a4566ebd61 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 16:34:27 +0800 Subject: [PATCH 36/95] Implement ImageBase class --- carta/image.py | 113 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 79 insertions(+), 34 deletions(-) diff --git a/carta/image.py b/carta/image.py index ea3945f..0fc0506 100644 --- a/carta/image.py +++ b/carta/image.py @@ -1,11 +1,11 @@ -"""This module contains an image class which represents a single image open in the session. +"""This module contains the image classes representing image-view items open in the session. 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, Point as Pt +from .constants import ImageType, Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit +from .util import Macro, cached, BasePathMixin, CartaScriptingException, 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 @@ -16,8 +16,41 @@ from .region import RegionSet -class Image(BasePathMixin): - """This object corresponds to an image open in a CARTA frontend session. +class ImageBase: + """Base class for image-view items (file-based images and color blendings). + + This class is not intended to be instantiated directly. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this image-view item. + """ + + _image_type: ImageType = None + + def __init__(self, session): + self.session = session + + @property + def _stable_id(self): + """The stable identifier of this image-view item.""" + raise NotImplementedError + + @property + def image_view_order(self): + """The index of this item in image list.""" + raise NotImplementedError + + def make_active(self): + """Make this the active image-view item.""" + self.session.call_action( + "setActiveImageById", self._image_type, self._stable_id + ) + + +class Image(ImageBase, BasePathMixin): + """This object corresponds to a file-based image open in a CARTA frontend session. This class should not be instantiated directly. Instead, use the session object's methods for opening new images or retrieving existing images. @@ -25,15 +58,15 @@ class Image(BasePathMixin): ---------- session : :obj:`carta.session.Session` The session object associated with this image. - image_id : integer - The ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. + file_id : integer + The frontend file id identifying this image. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. - image_id : integer - The ID identifying this image within the session. + file_id : integer + The frontend file id identifying this image. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` @@ -46,11 +79,13 @@ class Image(BasePathMixin): Functions for manipulating regions associated with this image. """ - def __init__(self, session, image_id): + _image_type = ImageType.FRAME + + def __init__(self, session, file_id): self.session = session - self.image_id = image_id + self.file_id = file_id - self._base_path = f"frameMap[{image_id}]" + self._base_path = f"frameMap[{file_id}]" self._frame = Macro("", self._base_path) # Sub-objects grouping related functions @@ -60,6 +95,10 @@ def __init__(self, session, image_id): self.wcs = ImageWCSOverlay(self) self.regions = RegionSet(self) + @property + def _stable_id(self): + return self.file_id + @classmethod def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_active=True, update_directory=False): """Open or append a new image in the session and return an image object associated with it. @@ -98,32 +137,42 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ params.append(make_active) params.append(update_directory) - image_id = session.call_action(command, *params, return_path="frameInfo.fileId") - return cls(session, image_id) - - @classmethod - def from_list(cls, session, image_list): - """Create a list of image objects from a list of open images retrieved from the frontend. + file_id = session.call_action(command, *params, return_path="frameInfo.fileId") + return cls(session, file_id) - This method should not be used directly. It is wrapped by :obj:`carta.session.Session.image_list`. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - image_list : list of dicts - The JSON object representing frame names retrieved from the frontend. + @property + def image_view_order(self): + """The current index of this image in image list. Returns ------- - list of :obj:`carta.image.Image` - A list of new image objects. + integer + The image view order. + + Raises + ------ + RuntimeError + If no matching frame entry exists in the image list. """ - return [cls(session, f["value"]) for f in image_list] + return self.session._find_image_view_order(ImageType.FRAME, self.file_id) def __repr__(self): """A human-readable representation of this image object.""" - return f"{self.session.session_id}:{self.image_id}:{self.file_name}" + cls = type(self).__name__ + cached_name = getattr(self, "_cache", {}).get("file_name") + name_part = f", file_name={cached_name!r}" if cached_name is not None else "" + + try: + order = self.image_view_order + except (CartaScriptingException, RuntimeError): + return f"[Invalid] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" + + try: + name = self.file_name + except CartaScriptingException: + return f"[Invalid] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" + + return f"{cls}(image_view_order={order}, file_name={name!r}, file_id={self.file_id})" # METADATA @@ -253,10 +302,6 @@ def polarizations(self): # SELECTION - def make_active(self): - """Make this the active image.""" - self.session.call_action("setActiveImageByFileId", self.image_id) - def make_spatial_reference(self): """Make this image the spatial reference.""" self.session.call_action("setSpatialReference", self._frame) From 6c2d9a15ea886bec0be576476275aa04c859a57a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 16:43:18 +0800 Subject: [PATCH 37/95] Update Image.__repr__ to use [Closed] label instead of [Invalid] for inaccessible images --- carta/image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/carta/image.py b/carta/image.py index 0fc0506..98f7ed3 100644 --- a/carta/image.py +++ b/carta/image.py @@ -165,12 +165,12 @@ def __repr__(self): try: order = self.image_view_order except (CartaScriptingException, RuntimeError): - return f"[Invalid] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" try: name = self.file_name except CartaScriptingException: - return f"[Invalid] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" return f"{cls}(image_view_order={order}, file_name={name!r}, file_id={self.file_id})" From ace85cbb7bed841460eead932597b5f99c05ee17 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 17:08:22 +0800 Subject: [PATCH 38/95] Update ColorBlending and Layer to use color_blending_id and improve __repr__ methods --- carta/colorblending.py | 223 ++++++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 101 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index c6d5bc0..a395e46 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,9 +1,10 @@ """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 .image import Image, ImageBase +from .util import BasePathMixin, CartaActionFailed, CartaScriptingException, Macro from .validation import ( + Any, Boolean, Constant, Coordinate, @@ -62,15 +63,30 @@ def from_list(cls, colorblending, layer_ids): return [cls(colorblending, layer_id) for layer_id in layer_ids] def __repr__(self): - """A human-readable representation of this object.""" - session_id = self.session.session_id - cb_imageview_id = self.colorblending.imageview_id - cb_name = self.colorblending.file_name - repr_content = [ - f"{session_id}:{cb_imageview_id}:{cb_name}", - f"{self.layer_id}:{self.file_name}", - ] - return ":".join(repr_content) + """A human-readable representation of this layer.""" + cls = type(self).__name__ + cb_id = self.colorblending.color_blending_id + + try: + order = self.colorblending.image_view_order + except (CartaScriptingException, RuntimeError): + return ( + f"[Closed] {cls}(image_view_order=None, " + f"color_blending_id={cb_id}, layer_id={self.layer_id})" + ) + + try: + name = self.file_name + except CartaScriptingException: + return ( + f"[Closed] {cls}(image_view_order={order}, " + f"color_blending_id={cb_id}, layer_id={self.layer_id})" + ) + + return ( + f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " + f"layer_id={self.layer_id}, file_name={name!r})" + ) @property def file_name(self): @@ -84,13 +100,13 @@ def file_name(self): return self.get_value("frameInfo.fileInfo.name") @property - def image_id(self): - """The ID of the image. + def file_id(self): + """The frontend file id of the layer's underlying image. Returns ------- integer - The image ID. + The file id. """ return self.get_value("frameInfo.fileId") @@ -120,81 +136,90 @@ def set_colormap(self, colormap, invert=False): self.call_action("renderConfig.setInverted", invert) -class ColorBlending(BasePathMixin): +class ColorBlending(ImageBase, BasePathMixin): """This object represents a color blending image in a session. Parameters ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - store_id : integer - The color blending store ID of the color blending image. + color_blending_id : integer + The id of the backing ``ColorBlendingStore`` on the frontend. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this color blending. - store_id : integer - The color blending store ID of the color blending image. + color_blending_id : integer + The id of the backing ``ColorBlendingStore`` on the frontend. """ - # Mirrors ColorBlendingStore.DEFAULT_LAYER_LIMIT in carta-frontend. - MAX_INITIAL_LAYERS = 10 + _image_type = ImageType.COLOR_BLENDING - def __init__(self, session, store_id): + def __init__(self, session, color_blending_id): self.session = session - self.store_id = store_id + self.color_blending_id = color_blending_id path = "imageViewConfigStore.colorBlendingImageMap" - self._base_path = f"{path}[{self.store_id}]" + self._base_path = f"{path}[{self.color_blending_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)." - ) + @property + def _stable_id(self): + return self.color_blending_id @classmethod - def from_imageview_id(cls, session, imageview_id): - """Create a color blending object from an image view ID. + def from_image_view_order(cls, session, image_view_order): + """Create a color blending object from an image view order. 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. + image_view_order : integer + The image-view order of the color blending image. Returns ------- :obj:`carta.colorblending.ColorBlending` A new color blending object. + + Raises + ------ + ValueError + If the entry at the given image-view order is not a color blending image. + IndexError + If ``image_view_order`` is out of range. """ - # 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: + summary = session.get_value("imageViewConfigStore.imageListSummary") + if image_view_order < 0 or image_view_order >= len(summary): + raise IndexError( + f"image_view_order {image_view_order} is out of range for " + f"an image list of length {len(summary)}." + ) + entry = summary[image_view_order] + if entry["type"] != ImageType.COLOR_BLENDING: raise ValueError( - "imageview_id does not refer to a color blending image." + "image_view_order does not refer to a color blending image." ) - store_id = session.get_value(f"{path}.store.id") - return cls(session, store_id) + return cls(session, entry["id"]) @classmethod + @validate(Any(), IterableOf(InstanceOf(Image), min_size=1)) def from_images(cls, session, images): """Create a color blending object from a list of images. + Side effect: this overwrites the session-wide spatial reference + to ``images[0]`` and spatially matches each of ``images[1:]`` to + it. + Parameters ---------- session : :obj:`carta.session.Session` The session object. - images : list of :obj:`carta.image.Image` - The images to be blended. + images : {1} + The images to be blended. Must be non-empty. The first entry + becomes the base layer. Returns ------- @@ -203,40 +228,25 @@ def from_images(cls, session, images): Raises ------ - ValueError - If more images are provided than the frontend can include when - initializing the color blending layers. + CartaValidationFailed + If ``images`` is empty or contains a non-:obj:`carta.image.Image` + value. + CartaActionFailed + If the atomic frontend action fails. In practice this + happens when the input contains a stale/closed frame or + exceeds the frontend's layer-count limit. """ - cls._validate_initial_layer_count(len(images)) - - # Set the first image as the spatial reference - session.call_action("setSpatialReference", images[0]._frame, False) - # Align the other images to the spatial reference - for image in images[1:]: - success = image.call_action( - "setSpatialReference", images[0]._frame + result = session.call_action( + "imageViewConfigStore.createColorBlendingFromFrames", + [image._frame for image in images], + ) + if result is None: + raise CartaActionFailed( + "Failed to create color blending: the frontend returned " + "null. This indicates a stale frame or a layer-count " + "limit exceeded." ) - if not success: - name = image.file_name - raise CartaActionFailed( - f"Failed to set spatial reference for image {name}." - ) - - command = "imageViewConfigStore.createColorBlending" - store_id = session.call_action(command, return_path="id") - colorblending = cls(session, store_id) - - # The frontend initializes color blending from the current spatial - # reference's secondarySpatialImages, which can include frames matched - # before this helper was called. Rebuild the non-base layers so the - # blend contains exactly the images requested here without clearing the - # session-wide spatial matching state. - for _ in colorblending.layer_list()[1:]: - colorblending.delete_layer(1) - for image in images[1:]: - colorblending.add_layer(image) - - return colorblending + return cls(session, result["id"]) @classmethod def from_files(cls, session, files, append=False): @@ -258,14 +268,34 @@ 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) 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}" + cls = type(self).__name__ + + try: + order = self.image_view_order + except (CartaScriptingException, RuntimeError): + return ( + f"[Closed] {cls}(image_view_order=None, " + f"color_blending_id={self.color_blending_id})" + ) + + try: + name = self.file_name + except CartaScriptingException: + return ( + f"[Closed] {cls}(image_view_order={order}, " + f"color_blending_id={self.color_blending_id})" + ) + + return ( + f"{cls}(image_view_order={order}, " + f"color_blending_id={self.color_blending_id}, " + f"file_name={name!r})" + ) @property def _base_frame(self): @@ -283,26 +313,21 @@ def file_name(self): return self.get_value("filename") @property - def imageview_id(self): - """The image view ID of the color blending image. + def image_view_order(self): + """The current index of this color blending in image list. Returns ------- integer - The image view ID. + The image view order. + + Raises + ------ + RuntimeError + If no matching color blending entry exists in the image list. """ - path = "imageViewConfigStore.imageList" - length = self.session.get_value(f"{path}.length") - for idx in range(length): - entry = f"{path}[{idx}]" - entry_type = self.session.get_value(f"{entry}.type") - if entry_type != ImageType.COLOR_BLENDING: - continue - entry_id = self.session.get_value(f"{entry}.store.id") - if entry_id == self.store_id: - return idx - raise RuntimeError( - "Could not find this color blending image in the image list." + return self.session._find_image_view_order( + ImageType.COLOR_BLENDING, self.color_blending_id ) @property @@ -316,10 +341,6 @@ def alpha(self): """ 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 @@ -416,7 +437,7 @@ def set_layer_sequence(self, layer_indices): current_alpha_values = self.alpha target_layer_states = [ ( - Image(self.session, current_layers[layer_index].image_id), + Image(self.session, current_layers[layer_index].file_id), current_alpha_values[layer_index], ) for layer_index in layer_indices[1:] From 566dfb0e72047fe8f388c7b0428ef97d2f17c07b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 17:20:47 +0800 Subject: [PATCH 39/95] Add get_image method and refactor image list handling to support heterogeneous image-view items --- carta/session.py | 187 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 170 insertions(+), 17 deletions(-) diff --git a/carta/session.py b/carta/session.py index e924ccd..312b060 100644 --- a/carta/session.py +++ b/carta/session.py @@ -11,7 +11,7 @@ from .image import Image from .colorblending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, Polarization +from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt @@ -508,31 +508,184 @@ def open_hypercube(self, image_paths, append=False): output_directory = self.pwd() output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" - image_id = self.call_action(command, stokes_images, output_directory, output_hdu) - return Image(self, image_id) + file_id = self.call_action(command, stokes_images, output_directory, output_hdu) + return Image(self, file_id) def image_list(self): - """Return the list of currently open images. + """Return the list of currently open image-view items. Returns ------- - list of :obj:`carta.image.Image` objects - The list of images open in this session. + list of :obj:`carta.image.ImageBase` + The heterogeneous list of image-view items open in this session. + """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + result = [] + for order, entry in enumerate(summary): + entry_type = entry["type"] + if entry_type == ImageType.FRAME: + result.append(Image(self, entry["id"])) + elif entry_type == ImageType.COLOR_BLENDING: + result.append(ColorBlending(self, entry["id"])) + else: + raise NotImplementedError( + f"image_list encountered an unsupported image-view " + f"entry at order {order} with type {entry_type!r}; " + "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " + "entries are currently wrapped." + ) + return result + + def _find_image_view_order(self, image_type, stable_id): + """Return the image-view order of an item identified by a stable id. + + Parameters + ---------- + image_type : :obj:`carta.constants.ImageType` + The image-view item type. + stable_id : integer + The stable id for that type (``file_id`` for frames, + ``color_blending_id`` for color blendings). + + Returns + ------- + integer + The image-view order of the first matching entry. + + Raises + ------ + RuntimeError + If no matching entry exists in the image list. + """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + for idx, entry in enumerate(summary): + if entry["type"] == image_type and entry["id"] == stable_id: + return idx + raise RuntimeError( + f"Could not find an image of type {image_type!r} with id " + f"{stable_id} in the image list." + ) + + def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=None): + """Return the image-view item identified by exactly one of the supported identifiers. + + Parameters + ---------- + image_view_order : integer, optional + The index of the item in the image list. + Returns whichever concrete wrapper (:obj:`carta.image.Image` + or :obj:`carta.colorblending.ColorBlending`) matches the + entry type at that position. Raises :obj:`NotImplementedError` + for any future entry type that is not yet wrapped on the + Python side. + file_id : integer, optional + The stable frontend file id of a normal frame-backed image. + color_blending_id : integer, optional + The stable id of a color blending. + + Returns + ------- + :obj:`carta.image.ImageBase` + The matching image-view item. + + Raises + ------ + ValueError + If zero or more than one of the keyword arguments is provided. + The error message lists the three accepted keyword names so + the API is discoverable from the exception alone. + IndexError + If ``image_view_order`` is out of range. + RuntimeError + If no matching entry exists for the given ``file_id`` or + ``color_blending_id``. There is no cross-type fallback. """ - return Image.from_list(self, self.get_value("frameNames")) + provided = { + "image_view_order": image_view_order, + "file_id": file_id, + "color_blending_id": color_blending_id, + } + given = [key for key, val in provided.items() if val is not None] + if len(given) != 1: + raise ValueError( + "get_image requires exactly one of the keyword arguments " + "`image_view_order`, `file_id`, or `color_blending_id`; " + f"got {len(given)}." + ) + + summary = self.get_value("imageViewConfigStore.imageListSummary") + + if image_view_order is not None: + if image_view_order < 0 or image_view_order >= len(summary): + raise IndexError( + f"image_view_order {image_view_order} is out of range " + f"for an image list of length {len(summary)}." + ) + entry = summary[image_view_order] + entry_type = entry["type"] + entry_id = entry["id"] + if entry_type == ImageType.FRAME: + return Image(self, entry_id) + if entry_type == ImageType.COLOR_BLENDING: + return ColorBlending(self, entry_id) + raise NotImplementedError( + f"get_image encountered an unsupported image-view entry " + f"at order {image_view_order} with type {entry_type!r}; " + "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " + "entries are currently wrapped." + ) + + if file_id is not None: + for entry in summary: + if entry["type"] == ImageType.FRAME and entry["id"] == file_id: + return Image(self, file_id) + raise RuntimeError( + f"No file-based image with file_id={file_id} is open." + ) + + # color_blending_id is not None + for entry in summary: + if ( + entry["type"] == ImageType.COLOR_BLENDING + and entry["id"] == color_blending_id + ): + return ColorBlending(self, color_blending_id) + raise RuntimeError( + f"No color blending with color_blending_id={color_blending_id} is open." + ) + + @validate(IterableOf(String()), Boolean()) + def open_as_color_blending(self, files, append=False): + """Open files and combine them into a new color blending image. - def color_blending_list(self): - """Return the list of currently open color blending objects. + Parameters + ---------- + files : {0} + The files to be blended. + append : {1} + Whether the images should be appended to existing images. By default this is ``False`` and any existing open images are closed. Returns ------- - list of :obj:`carta.colorblending.ColorBlending` objects - The list of color blending objects open in this session. + :obj:`carta.colorblending.ColorBlending` + The new color blending object. """ - 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] + return ColorBlending.from_files(self, files, append=append) + + def create_color_blending(self, images): + """Combine already-open images into a new color blending image. + + Parameters + ---------- + images : list of :obj:`carta.image.Image` + The images to be blended. The first entry becomes the base layer. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + The new color blending object. + """ + return ColorBlending.from_images(self, images) def active_frame(self): """Return the currently active image. @@ -542,8 +695,8 @@ def active_frame(self): :obj:`carta.image.Image` The currently active image. """ - image_id = self.get_value("activeFrame.frameInfo.fileId") - return Image(self, image_id) + file_id = self.get_value("activeFrame.frameInfo.fileId") + return Image(self, file_id) def image_by_id(self, image_id): """Return an image object with the specified ID. From 07353f199ca57a38dc3b67ac5bb39eaee7894cc0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 17:34:46 +0800 Subject: [PATCH 40/95] Filter image_list to return only Image instances in _images helper method --- carta/wcs_overlay.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index a82a4f7..6d26829 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -437,8 +437,9 @@ class ImageWCSConnector: def _images(self, image_ids=None): """Internal helper function for fetching image objects.""" + from .image import Image if image_ids is None: - return self.session.image_list() + return [img for img in self.session.image_list() if isinstance(img, Image)] return [self.session.image_by_id(image_id) for image_id in image_ids] def _get_image_wcs_properties(self, image_ids, property_path): From 4302da5e4351d85873a3779b42f21f2dd42c8635 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 18:19:20 +0800 Subject: [PATCH 41/95] Update tests --- tests/test_colorblending.py | 331 +++++++++++++++++++----------------- tests/test_image.py | 132 +++++++++++++- tests/test_session.py | 179 ++++++++++++++++--- 3 files changed, 462 insertions(+), 180 deletions(-) diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 61f8ef3..3c569e0 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -69,13 +69,41 @@ def test_layer_from_list(colorblending): 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") +def test_layer_repr_healthy(session, colorblending, layer_property, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=2) 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" + assert r == ( + "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " + "file_name='layer1.fits')" + ) + + +def test_layer_repr_closed_when_parent_missing(session, colorblending, mocker): + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + r = repr(Layer(colorblending, 3)) + assert r == ( + "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "layer_id=3)" + ) + + +def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch( + "carta.colorblending.Layer.file_name", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("frame is gone"), + ) + r = repr(Layer(colorblending, 3)) + assert r == ( + "[Closed] Layer(image_view_order=2, color_blending_id=0, " + "layer_id=3)" + ) def test_layer_file_name_property(layer, layer_get_value): @@ -83,8 +111,8 @@ def test_layer_file_name_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileInfo.name") -def test_layer_image_id_property(layer, layer_get_value): - layer.image_id +def test_layer_file_id_property(layer, layer_get_value): + layer.file_id layer_get_value.assert_called_with("frameInfo.fileId") @@ -119,7 +147,7 @@ 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.color_blending_id == 3 expected = "imageViewConfigStore.colorBlendingImageMap[3]" assert colorblending._base_path == expected assert colorblending._frame == Macro( @@ -127,10 +155,43 @@ def test_colorblending_init(session): ) -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_repr_healthy(session, colorblending, cb_property, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=2) + cb_property("file_name", "Color Blending 1") + r = repr(colorblending) + assert r == ( + "ColorBlending(image_view_order=2, color_blending_id=0, " + "file_name='Color Blending 1')" + ) + + +def test_colorblending_repr_closed_when_not_in_image_list( + session, colorblending, mocker +): + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + r = repr(colorblending) + assert r == ( + "[Closed] ColorBlending(image_view_order=None, color_blending_id=0)" + ) + + +def test_colorblending_repr_closed_when_backing_entry_is_gone( + session, colorblending, mocker +): + mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch( + "carta.colorblending.ColorBlending.file_name", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("color blending is gone"), + ) + r = repr(colorblending) + assert r == ( + "[Closed] ColorBlending(image_view_order=2, color_blending_id=0)" + ) def test_colorblending_file_name(colorblending, cb_get_value): @@ -138,17 +199,38 @@ 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): - # 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 +def test_colorblending_image_view_order( + session, colorblending, session_get_value +): + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 99}, + {"type": ImageType.COLOR_BLENDING, "id": 0}, + ] + assert colorblending.image_view_order == 2 + session_get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_colorblending_image_view_order_ignores_non_color_blending( + session, colorblending, session_get_value +): + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 0}, + {"type": ImageType.COLOR_BLENDING, "id": 0}, ] - assert colorblending.imageview_id == 2 + assert colorblending.image_view_order == 1 + + +def test_colorblending_image_view_order_raises_when_missing( + session, colorblending, session_get_value +): + session_get_value.return_value = [ + {"type": ImageType.COLOR_BLENDING, "id": 99}, + ] + with pytest.raises(RuntimeError): + colorblending.image_view_order def test_colorblending_alpha(colorblending, cb_get_value): @@ -163,15 +245,24 @@ def test_colorblending_base_frame(colorblending, cb_get_value): 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 + assert base_frame.file_id == 42 + + +def test_colorblending_make_active(session, colorblending, session_call_action): + # make_active must be driven by color_blending_id via setActiveImageById. + # It must not depend on image_view_order (which is volatile). + colorblending.make_active() + session_call_action.assert_called_with( + "setActiveImageById", ImageType.COLOR_BLENDING, 0 + ) -def test_colorblending_make_active( - session, colorblending, cb_property, session_call_action +def test_colorblending_make_active_does_not_read_image_view_order( + session, colorblending, session_call_action, session_get_value ): - cb_property("imageview_id", 9) colorblending.make_active() - session_call_action.assert_called_with("setActiveImageByIndex", 9) + for call in session_get_value.call_args_list: + assert call.args != ("imageViewConfigStore.imageListSummary",) def test_colorblending_layer_list_derived(session, mocker): @@ -218,13 +309,14 @@ def test_colorblending_set_layer( ) -def test_colorblending_set_layer_sequence(session, colorblending, mocker): - # Prepare three existing layers with image_ids 10, 20, 30 - class _L: - def __init__(self, lid, iid): - self.layer_id = lid - self.image_id = iid +class _L: + def __init__(self, lid, fid): + self.layer_id = lid + self.file_id = fid + +def test_colorblending_set_layer_sequence(session, colorblending, mocker): + # Prepare three existing layers with file_ids 10, 20, 30 mocker.patch.object( ColorBlending, "layer_list", @@ -244,18 +336,13 @@ def __init__(self, lid, iid): # 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 [img.file_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", @@ -273,18 +360,13 @@ def __init__(self, lid, iid): 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[0].file_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", @@ -301,11 +383,6 @@ def __init__(self, lid, iid): 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", @@ -322,11 +399,6 @@ def __init__(self, lid, iid): 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", @@ -346,11 +418,6 @@ def __init__(self, lid, iid): 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", @@ -484,37 +551,44 @@ def test_colorblending_close(session, colorblending, session_call_action): # CREATION HELPERS -def test_colorblending_from_imageview_id(session, session_get_value): - session_get_value.side_effect = [ImageType.COLOR_BLENDING, 17] +def test_colorblending_from_image_view_order(session, session_get_value): + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.FRAME, "id": 20}, + {"type": ImageType.COLOR_BLENDING, "id": 17}, + ] - cb = ColorBlending.from_imageview_id(session, 5) + cb = ColorBlending.from_image_view_order(session, 2) assert isinstance(cb, ColorBlending) - assert cb.store_id == 17 + assert cb.color_blending_id == 17 expected = "imageViewConfigStore.colorBlendingImageMap[17]" assert cb._base_path == expected - assert [call.args for call in session_get_value.call_args_list] == [ - ("imageViewConfigStore.imageList[5].type",), - ("imageViewConfigStore.imageList[5].store.id",), - ] + session_get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) -def test_colorblending_from_imageview_id_rejects_non_color_blending( +def test_colorblending_from_image_view_order_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) + session_get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + ] with pytest.raises( ValueError, - match="imageview_id does not refer to a color blending image.", + match="image_view_order does not refer to a color blending image.", ): - ColorBlending.from_imageview_id(session, 5) + ColorBlending.from_image_view_order(session, 0) - session_get_value.assert_called_once_with( - "imageViewConfigStore.imageList[5].type" - ) - init.assert_not_called() + +def test_colorblending_from_image_view_order_out_of_range( + session, session_get_value +): + session_get_value.return_value = [] + with pytest.raises(IndexError): + ColorBlending.from_image_view_order(session, 0) def test_colorblending_from_images_success(session, mocker): @@ -522,78 +596,46 @@ def test_colorblending_from_images_success(session, mocker): img1 = Image(session, 200) img2 = Image(session, 300) - mocker.patch.object(session, "call_action") - mocker.patch.object(img1, "call_action", return_value=True) - mocker.patch.object(img2, "call_action", return_value=True) - layer_list = mocker.patch.object( - ColorBlending, - "layer_list", - autospec=True, - return_value=[object(), object(), object(), object()], - ) - delete_layer = mocker.patch.object( - ColorBlending, "delete_layer", autospec=True - ) - add_layer = mocker.patch.object(ColorBlending, "add_layer", autospec=True) - - session.call_action.side_effect = [None, 123] + call_action = mocker.patch.object(session, "call_action", return_value={"id": 123}) cb = ColorBlending.from_images(session, [img0, img1, img2]) assert isinstance(cb, ColorBlending) - assert cb.store_id == 123 + assert cb.color_blending_id == 123 assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" - session.call_action.assert_any_call( - "setSpatialReference", img0._frame, False - ) - img1.call_action.assert_called_with("setSpatialReference", img0._frame) - img2.call_action.assert_called_with("setSpatialReference", img0._frame) - session.call_action.assert_called_with( - "imageViewConfigStore.createColorBlending", return_path="id" - ) - layer_list.assert_called_once_with(cb) - delete_layer.assert_has_calls( - [mocker.call(cb, 1), mocker.call(cb, 1), mocker.call(cb, 1)] + + call_action.assert_called_once_with( + "imageViewConfigStore.createColorBlendingFromFrames", + [img0._frame, img1._frame, img2._frame], ) - add_layer.assert_has_calls([mocker.call(cb, img1), mocker.call(cb, img2)]) -def test_colorblending_from_images_alignment_failure( - session, mocker, mock_property +def test_colorblending_from_images_null_return_raises_action_failed( + session, mocker ): img0 = Image(session, 100) - img1 = Image(session, 200) + mocker.patch.object(session, "call_action", return_value=None) - mocker.patch.object(session, "call_action") - mock_property("carta.image.Image")("file_name", "bad.fits") - mocker.patch.object(img1, "call_action", return_value=False) + with pytest.raises(CartaActionFailed): + ColorBlending.from_images(session, [img0]) - with pytest.raises(CartaActionFailed) as e: - ColorBlending.from_images(session, [img0, img1]) - assert "Failed to set spatial reference for image bad.fits." in str( - e.value - ) +def test_colorblending_from_images_rejects_empty_list(session, mocker): + call_action = mocker.patch.object(session, "call_action") -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(CartaValidationFailed): + ColorBlending.from_images(session, []) - 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) + call_action.assert_not_called() + + +def test_colorblending_from_images_rejects_non_image_element(session, mocker): + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed): + ColorBlending.from_images(session, ["not-an-image"]) - session_call_action.assert_not_called() + call_action.assert_not_called() def test_colorblending_from_files(session, mocker): @@ -607,26 +649,5 @@ def test_colorblending_from_files(session, mocker): ) 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() + mock_from_images.assert_called_once() assert out == "CB" - - -def test_colorblending_from_files_rejects_more_than_initial_layer_limit( - session, mocker -): - files = [ - f"image-{file_id}.fits" - for file_id in range(ColorBlending.MAX_INITIAL_LAYERS + 1) - ] - mock_open_images = mocker.patch.object(session, "open_images") - - with pytest.raises( - ValueError, - match=( - "Color blending initialization supports at most 10 images " - r"\(the base layer plus 9 matched images\)." - ), - ): - ColorBlending.from_files(session, files) - - mock_open_images.assert_not_called() diff --git a/tests/test_image.py b/tests/test_image.py index e8bf392..589e71b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,8 +1,8 @@ import pytest -from carta.image import Image -from carta.util import CartaValidationFailed, Point as Pt -from carta.constants import NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU +from carta.image import Image, ImageBase +from carta.util import CartaActionFailed, CartaValidationFailed, Point as Pt +from carta.constants import ImageType, NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU # FIXTURES @@ -77,7 +77,7 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte assert type(image_object) is Image assert image_object.session == session - assert image_object.image_id == 123 + assert image_object.file_id == 123 # SUBOBJECTS @@ -110,7 +110,129 @@ 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("setActiveImageByFileId", 0) + session_call_action.assert_called_with( + "setActiveImageById", ImageType.FRAME, 0 + ) + + +def test_image_base_image_view_order_not_implemented(session): + base = ImageBase(session) + with pytest.raises(NotImplementedError): + base.image_view_order + + +def test_image_base_stable_id_not_implemented(session): + base = ImageBase(session) + with pytest.raises(NotImplementedError): + base._stable_id + + +def test_image_base_make_active_uses_subclass_ids(session, session_call_action): + # Verify the shared ImageBase.make_active dispatches setActiveImageById + # with the subclass's _image_type and _stable_id exactly once. + class Dummy(ImageBase): + _image_type = ImageType.FRAME + + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + Dummy(session, 42).make_active() + session_call_action.assert_called_once_with( + "setActiveImageById", ImageType.FRAME, 42 + ) + + +def test_image_view_order_uses_summary_once(session, mocker, image): + find = mocker.patch.object( + session, "_find_image_view_order", return_value=3 + ) + # Frame with file_id=0 at viewer order 3. + assert image.image_view_order == 3 + find.assert_called_once_with(ImageType.FRAME, 0) + + +def test_image_view_order_ignores_non_frame_entries(session, mocker): + get_value = mocker.patch.object( + session, + "get_value", + return_value=[ + {"type": ImageType.COLOR_BLENDING, "id": 0}, + {"type": ImageType.FRAME, "id": 7}, + {"type": ImageType.FRAME, "id": 3}, + ], + ) + img = Image(session, 3) + assert img.image_view_order == 2 + get_value.assert_called_once_with("imageViewConfigStore.imageListSummary") + + +def test_image_view_order_raises_when_missing(session, mocker): + mocker.patch.object( + session, + "get_value", + return_value=[{"type": ImageType.FRAME, "id": 99}], + ) + img = Image(session, 3) + with pytest.raises(RuntimeError): + img.image_view_order + + +def test_image_repr_cached_name_resolves_only_image_view_order(session, image, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=3) + get_value = mocker.patch.object(image, "get_value") + image._cache = {"file_name": "cube.fits"} + r = repr(image) + assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + get_value.assert_not_called() + + +def test_image_repr_resolves_image_view_order_and_file_name(session, image, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=3) + mocker.patch.object(image, "get_value", return_value="cube.fits") + r = repr(image) + assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + + +def test_image_repr_closed_when_image_view_order_missing(session, image, mocker): + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + r = repr(image) + assert r == "[Closed] Image(image_view_order=None, file_id=0)" + + +def test_image_repr_closed_shows_cached_file_name(session, image, mocker): + # When the image-view-order lookup fails but file_name was previously + # cached, the closed repr still surfaces the cached name without + # triggering any fresh round-trip. + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + get_value = mocker.patch.object(image, "get_value") + image._cache = {"file_name": "cube.fits"} + r = repr(image) + assert r == "[Closed] Image(image_view_order=None, file_name='cube.fits', file_id=0)" + get_value.assert_not_called() + + +def test_image_repr_closed_when_frame_is_gone(session, image, mocker): + mocker.patch.object(session, "_find_image_view_order", return_value=3) + mocker.patch.object( + image, + "get_value", + side_effect=CartaActionFailed("frameMap entry is missing"), + ) + r = repr(image) + assert r == "[Closed] Image(image_view_order=3, file_id=0)" @pytest.mark.parametrize("channel", [0, 10, 19]) diff --git a/tests/test_session.py b/tests/test_session.py index 3fb07db..ff016f9 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,7 +3,7 @@ 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 +from carta.constants import ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -71,31 +71,170 @@ def test_cd(session, method, call_action): 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] +# IMAGE LIST / GET_IMAGE / COLOR-BLENDING HELPERS - color_blendings = session.color_blending_list() - assert len(color_blendings) == 2 - assert all(isinstance(cb, ColorBlending) for cb in color_blendings) - get_value.assert_any_call("imageViewConfigStore.colorBlendingImages.length") - get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[0].id") - get_value.assert_any_call("imageViewConfigStore.colorBlendingImages[1].id") - assert [cb.session for cb in color_blendings] == [session, session] - assert [cb.store_id for cb in color_blendings] == [3, 8] - assert [cb._base_path for cb in color_blendings] == [ - "imageViewConfigStore.colorBlendingImageMap[3]", - "imageViewConfigStore.colorBlendingImageMap[8]", +def test_image_list_heterogeneous(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.FRAME, "id": 20}, ] + images = session.image_list() + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + assert len(images) == 3 + assert isinstance(images[0], Image) and images[0].file_id == 10 + assert isinstance(images[1], ColorBlending) and images[1].color_blending_id == 3 + assert isinstance(images[2], Image) and images[2].file_id == 20 + + +def test_image_list_raises_on_pv_preview(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.PV_PREVIEW, "id": -2}, + ] + with pytest.raises(NotImplementedError): + session.image_list() -def test_color_blending_list_empty(session, get_value): - get_value.return_value = 0 - assert session.color_blending_list() == [] +def test_image_list_empty(session, get_value): + get_value.return_value = [] + assert session.image_list() == [] get_value.assert_called_once_with( - "imageViewConfigStore.colorBlendingImages.length" + "imageViewConfigStore.imageListSummary" + ) + + +def test_find_image_view_order_single_round_trip(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 7}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + {"type": ImageType.FRAME, "id": 3}, + ] + + assert session._find_image_view_order(ImageType.FRAME, 3) == 2 + assert session._find_image_view_order(ImageType.COLOR_BLENDING, 7) == 1 + assert get_value.call_count == 2 + for call in get_value.call_args_list: + assert call.args == ("imageViewConfigStore.imageListSummary",) + + +def test_find_image_view_order_raises_when_missing(session, get_value): + get_value.return_value = [{"type": ImageType.FRAME, "id": 1}] + with pytest.raises(RuntimeError): + session._find_image_view_order(ImageType.FRAME, 99) + + +# session.get_image + + +@pytest.fixture +def summary(get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + {"type": ImageType.FRAME, "id": 20}, + ] + return get_value + + +def test_get_image_requires_exactly_one_keyword(session, get_value): + # Zero keywords -> ValueError with all three names listed. + with pytest.raises(ValueError) as e: + session.get_image() + for name in ("image_view_order", "file_id", "color_blending_id"): + assert name in str(e.value) + # Multiple keywords -> ValueError. + with pytest.raises(ValueError): + session.get_image(file_id=1, color_blending_id=2) + + +def test_get_image_rejects_positional(session): + with pytest.raises(TypeError): + session.get_image(0) + + +def test_get_image_by_image_view_order(session, summary): + img = session.get_image(image_view_order=0) + assert isinstance(img, Image) + assert img.file_id == 10 + + cb = session.get_image(image_view_order=1) + assert isinstance(cb, ColorBlending) + assert cb.color_blending_id == 7 + + img2 = session.get_image(image_view_order=2) + assert isinstance(img2, Image) + assert img2.file_id == 20 + + +def test_get_image_by_image_view_order_out_of_range(session, summary): + with pytest.raises(IndexError): + session.get_image(image_view_order=99) + + +def test_get_image_by_file_id(session, summary): + img = session.get_image(file_id=20) + assert isinstance(img, Image) + assert img.file_id == 20 + + +def test_get_image_by_file_id_no_cross_type_fallback(session, summary): + # The summary contains a COLOR_BLENDING entry with id=7, but no FRAME + # with that id, so get_image(file_id=7) must raise. + with pytest.raises(RuntimeError): + session.get_image(file_id=7) + + +def test_get_image_by_color_blending_id(session, summary): + cb = session.get_image(color_blending_id=7) + assert isinstance(cb, ColorBlending) + assert cb.color_blending_id == 7 + + +def test_get_image_by_color_blending_id_no_cross_type_fallback(session, summary): + # The summary contains a FRAME with id=10, but no COLOR_BLENDING with + # that id, so get_image(color_blending_id=10) must raise. + with pytest.raises(RuntimeError): + session.get_image(color_blending_id=10) + + +def test_get_image_single_round_trip(session, summary): + session.get_image(image_view_order=0) + session.get_image(file_id=10) + session.get_image(color_blending_id=7) + assert summary.call_count == 3 + for call in summary.call_args_list: + assert call.args == ("imageViewConfigStore.imageListSummary",) + + +# open_as_color_blending / create_color_blending + + +def test_open_as_color_blending_delegates_to_from_files(session, mocker): + mock_from_files = mocker.patch.object( + ColorBlending, "from_files", return_value="CB" + ) + result = session.open_as_color_blending(["a.fits", "b.fits"], append=True) + mock_from_files.assert_called_once_with( + session, ["a.fits", "b.fits"], append=True + ) + assert result == "CB" + + +def test_create_color_blending_delegates_to_from_images(session, mocker): + img0 = Image(session, 100) + img1 = Image(session, 200) + mock_from_images = mocker.patch.object( + ColorBlending, "from_images", return_value="CB" ) + result = session.create_color_blending([img0, img1]) + mock_from_images.assert_called_once_with(session, [img0, img1]) + assert result == "CB" # OPENING IMAGES @@ -206,7 +345,7 @@ def test_open_hypercube_guess_polarization(mocker, session, call_action, method, assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.image_id == 123 + assert hypercube.file_id == 123 @pytest.mark.parametrize("paths,expected_calls,mocked_side_effect,expected_error", [ @@ -260,7 +399,7 @@ def test_open_hypercube_explicit_polarization(mocker, session, call_action, meth assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.image_id == 123 + assert hypercube.file_id == 123 @pytest.mark.parametrize("paths,expected_error", [ From d424e901dc5ff37bf1691854db5d2a489a472970 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 19:30:05 +0800 Subject: [PATCH 42/95] Update terminology from "file-based" to "frame-backed" in image docstring and error messages --- carta/image.py | 4 ++-- carta/session.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/image.py b/carta/image.py index 98f7ed3..4945eb0 100644 --- a/carta/image.py +++ b/carta/image.py @@ -17,7 +17,7 @@ class ImageBase: - """Base class for image-view items (file-based images and color blendings). + """Base class for image-view items (frame-backed images and color blendings). This class is not intended to be instantiated directly. @@ -50,7 +50,7 @@ def make_active(self): class Image(ImageBase, BasePathMixin): - """This object corresponds to a file-based image open in a CARTA frontend session. + """This object corresponds to a frame-backed image open in a CARTA frontend session. This class should not be instantiated directly. Instead, use the session object's methods for opening new images or retrieving existing images. diff --git a/carta/session.py b/carta/session.py index 312b060..5a8c17e 100644 --- a/carta/session.py +++ b/carta/session.py @@ -640,7 +640,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No if entry["type"] == ImageType.FRAME and entry["id"] == file_id: return Image(self, file_id) raise RuntimeError( - f"No file-based image with file_id={file_id} is open." + f"No frame-backed image with file_id={file_id} is open." ) # color_blending_id is not None From 5c9e334a65d35a4136bcf174b99709edc3e335bc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 20:01:13 +0800 Subject: [PATCH 43/95] Set default colormap set based on number of files for open_as_color_blending --- carta/session.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/carta/session.py b/carta/session.py index 5a8c17e..86f18b0 100644 --- a/carta/session.py +++ b/carta/session.py @@ -11,7 +11,7 @@ from .image import Image from .colorblending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization +from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt @@ -670,7 +670,12 @@ def open_as_color_blending(self, files, append=False): :obj:`carta.colorblending.ColorBlending` The new color blending object. """ - return ColorBlending.from_files(self, files, append=append) + cb = ColorBlending.from_files(self, files, append=append) + if len(files) <= 3: + cb.set_colormap_set(ColormapSet.RGB) + else: + cb.set_colormap_set(ColormapSet.RAINBOW) + return cb def create_color_blending(self, images): """Combine already-open images into a new color blending image. From ac58b5f9fa61863758c7d77a34e472ca10014044 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 20:51:46 +0800 Subject: [PATCH 44/95] Extract ImageBase class into separate module --- carta/colorblending.py | 3 ++- carta/image.py | 34 +--------------------------------- carta/image_base.py | 40 ++++++++++++++++++++++++++++++++++++++++ carta/session.py | 4 ++-- docs/source/carta.rst | 8 ++++++++ 5 files changed, 53 insertions(+), 36 deletions(-) create mode 100644 carta/image_base.py diff --git a/carta/colorblending.py b/carta/colorblending.py index a395e46..f5d233f 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,7 +1,8 @@ """This module contains functionality for interacting with color blending images and their layers.""" from .constants import Colormap, ColormapSet, ImageType -from .image import Image, ImageBase +from .image import Image +from .image_base import ImageBase from .util import BasePathMixin, CartaActionFailed, CartaScriptingException, Macro from .validation import ( Any, diff --git a/carta/image.py b/carta/image.py index 4945eb0..4bbca67 100644 --- a/carta/image.py +++ b/carta/image.py @@ -5,6 +5,7 @@ from .constants import ImageType, Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit +from .image_base import ImageBase from .util import Macro, cached, BasePathMixin, CartaScriptingException, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point @@ -16,39 +17,6 @@ from .region import RegionSet -class ImageBase: - """Base class for image-view items (frame-backed images and color blendings). - - This class is not intended to be instantiated directly. - - Attributes - ---------- - session : :obj:`carta.session.Session` - The session object associated with this image-view item. - """ - - _image_type: ImageType = None - - def __init__(self, session): - self.session = session - - @property - def _stable_id(self): - """The stable identifier of this image-view item.""" - raise NotImplementedError - - @property - def image_view_order(self): - """The index of this item in image list.""" - raise NotImplementedError - - def make_active(self): - """Make this the active image-view item.""" - self.session.call_action( - "setActiveImageById", self._image_type, self._stable_id - ) - - class Image(ImageBase, BasePathMixin): """This object corresponds to a frame-backed image open in a CARTA frontend session. diff --git a/carta/image_base.py b/carta/image_base.py new file mode 100644 index 0000000..bd0e8bf --- /dev/null +++ b/carta/image_base.py @@ -0,0 +1,40 @@ +"""This module contains the shared base class for image-view items (frame-backed images and color blendings). + +The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.colorblending.ColorBlending` can share a common protocol without one having to import the other. +""" + + +from .constants import ImageType + + +class ImageBase: + """Base class for image-view items (frame-backed images and color blendings). + + This class is not intended to be instantiated directly. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this image-view item. + """ + + _image_type: ImageType = None + + def __init__(self, session): + self.session = session + + @property + def _stable_id(self): + """The stable identifier of this image-view item.""" + raise NotImplementedError + + @property + def image_view_order(self): + """The index of this item in image list.""" + raise NotImplementedError + + def make_active(self): + """Make this the active image-view item.""" + self.session.call_action( + "setActiveImageById", self._image_type, self._stable_id + ) diff --git a/carta/session.py b/carta/session.py index 86f18b0..1be401b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -516,7 +516,7 @@ def image_list(self): Returns ------- - list of :obj:`carta.image.ImageBase` + list of :obj:`carta.image_base.ImageBase` The heterogeneous list of image-view items open in this session. """ summary = self.get_value("imageViewConfigStore.imageListSummary") @@ -585,7 +585,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No Returns ------- - :obj:`carta.image.ImageBase` + :obj:`carta.image_base.ImageBase` The matching image-view item. Raises diff --git a/docs/source/carta.rst b/docs/source/carta.rst index ed393e5..f1e13f5 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -49,6 +49,14 @@ carta.image module :undoc-members: :show-inheritance: +carta.image\_base module +------------------------ + +.. automodule:: carta.image_base + :members: + :undoc-members: + :show-inheritance: + carta.metadata module --------------------- From 7be1e6533591563391d6bd3f58d1801a878cf24e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 20:54:50 +0800 Subject: [PATCH 45/95] Add parametrized test for default colormap set selection in open_as_color_blending --- tests/test_session.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_session.py b/tests/test_session.py index ff016f9..3baaf05 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,7 +3,7 @@ from carta.image import Image from carta.colorblending import ColorBlending from carta.util import Macro -from carta.constants import ComplexComponent as CC, ImageType, Polarization as Pol +from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -215,15 +215,23 @@ def test_get_image_single_round_trip(session, summary): # open_as_color_blending / create_color_blending -def test_open_as_color_blending_delegates_to_from_files(session, mocker): +@pytest.mark.parametrize("files,expected_colormap_set", [ + # <= 3 files -> RGB + (["a.fits"], ColormapSet.RGB), + (["a.fits", "b.fits"], ColormapSet.RGB), + (["a.fits", "b.fits", "c.fits"], ColormapSet.RGB), + # > 3 files -> RAINBOW + (["a.fits", "b.fits", "c.fits", "d.fits"], ColormapSet.RAINBOW), +]) +def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, expected_colormap_set): + fake_cb = mocker.MagicMock(name="ColorBlending") mock_from_files = mocker.patch.object( - ColorBlending, "from_files", return_value="CB" - ) - result = session.open_as_color_blending(["a.fits", "b.fits"], append=True) - mock_from_files.assert_called_once_with( - session, ["a.fits", "b.fits"], append=True + ColorBlending, "from_files", return_value=fake_cb ) - assert result == "CB" + result = session.open_as_color_blending(files, append=True) + mock_from_files.assert_called_once_with(session, files, append=True) + fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) + assert result is fake_cb def test_create_color_blending_delegates_to_from_images(session, mocker): From 4a06836067841244c07bcd42bd7a848109ddc1e3 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 21:01:31 +0800 Subject: [PATCH 46/95] Reorganize ColorBlending methods with section comments for better code structure --- carta/colorblending.py | 82 ++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index f5d233f..d27a03a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -169,6 +169,8 @@ def __init__(self, session, color_blending_id): def _stable_id(self): return self.color_blending_id + # FACTORIES + @classmethod def from_image_view_order(cls, session, image_view_order): """Create a color blending object from an image view order. @@ -272,6 +274,24 @@ def from_files(cls, session, files, append=False): images = session.open_images(files, append=append) return cls.from_images(session, images) + @property + def image_view_order(self): + """The current index of this color blending in image list. + + Returns + ------- + integer + The image view order. + + Raises + ------ + RuntimeError + If no matching color blending entry exists in the image list. + """ + return self.session._find_image_view_order( + ImageType.COLOR_BLENDING, self.color_blending_id + ) + def __repr__(self): """A human-readable representation of this color blending object.""" cls = type(self).__name__ @@ -298,6 +318,8 @@ def __repr__(self): f"file_name={name!r})" ) + # METADATA + @property def _base_frame(self): return Image(self.session, self.get_value("frames[0].id")) @@ -313,23 +335,7 @@ def file_name(self): """ return self.get_value("filename") - @property - def image_view_order(self): - """The current index of this color blending in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching color blending entry exists in the image list. - """ - return self.session._find_image_view_order( - ImageType.COLOR_BLENDING, self.color_blending_id - ) + # LAYERS @property def alpha(self): @@ -342,6 +348,24 @@ def alpha(self): """ return self.get_value("alpha") + @validate(IterableOf(Number(0, 1))) + def set_alpha(self, alpha_list): + """Set the alpha value for the color blending layers. + + Parameters + ---------- + alpha_list : {0} + The alpha values. + """ + layer_list = self.layer_list() + if len(alpha_list) != len(layer_list): + raise ValueError( + f"alpha_list length ({len(alpha_list)}) does not match " + f"the number of layers ({len(layer_list)})." + ) + for alpha, layer in zip(alpha_list, layer_list): + layer.set_alpha(alpha) + def layer_list(self): """ Returns a list of Layer objects, each representing a layer in @@ -456,6 +480,8 @@ def set_layer_sequence(self, layer_indices): self.add_layer(image) Layer(self, target_layer_index).set_alpha(alpha) + # NAVIGATION + @validate(Coordinate(), Coordinate()) def set_center(self, x, y): """Set the center position, in image or world coordinates. @@ -503,6 +529,8 @@ def set_zoom_level(self, zoom, absolute=True): """ self._base_frame.set_zoom_level(zoom, absolute) + # RENDERING + @validate(Constant(ColormapSet)) def set_colormap_set(self, colormap_set): """Set the colormap set for the color blending. @@ -514,23 +542,7 @@ def set_colormap_set(self, colormap_set): """ self.call_action("applyColormapSet", colormap_set) - @validate(IterableOf(Number(0, 1))) - def set_alpha(self, alpha_list): - """Set the alpha value for the color blending layers. - - Parameters - ---------- - alpha_list : {0} - The alpha values. - """ - layer_list = self.layer_list() - if len(alpha_list) != len(layer_list): - raise ValueError( - f"alpha_list length ({len(alpha_list)}) does not match " - f"the number of layers ({len(layer_list)})." - ) - for alpha, layer in zip(alpha_list, layer_list): - layer.set_alpha(alpha) + # VISIBILITY @validate(Boolean()) def set_raster_visible(self, state): @@ -571,6 +583,8 @@ def set_vector_overlay_visible(self, state): if is_visible != state: self.call_action("toggleVectorOverlayVisible") + # CLOSE + def close(self): """Close this color blending object.""" self.session.call_action( From ec1501adc484edd8eaad155d7f151c614f1d7c48 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 21:14:47 +0800 Subject: [PATCH 47/95] Add image_view_order property to Layer class and update __repr__ to use it --- carta/colorblending.py | 25 +++++++++++++++++++- tests/test_colorblending.py | 47 ++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index d27a03a..0647f2a 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -63,13 +63,36 @@ def from_list(cls, colorblending, layer_ids): """ return [cls(colorblending, layer_id) for layer_id in layer_ids] + @property + def image_view_order(self): + """The image-view order of this layer's underlying frame. + + This is the position of the underlying frame in the session's image + list. A layer does not occupy its own position in the image list; + its parent color blending does (see + :obj:`carta.colorblending.ColorBlending.image_view_order`). + + Returns + ------- + integer + The image-view order of the underlying frame. + + Raises + ------ + RuntimeError + If no matching frame entry exists in the image list. + """ + return self.session._find_image_view_order( + ImageType.FRAME, self.file_id + ) + def __repr__(self): """A human-readable representation of this layer.""" cls = type(self).__name__ cb_id = self.colorblending.color_blending_id try: - order = self.colorblending.image_view_order + order = self.image_view_order except (CartaScriptingException, RuntimeError): return ( f"[Closed] {cls}(image_view_order=None, " diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 3c569e0..5e01400 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -70,16 +70,21 @@ def test_layer_from_list(colorblending): def test_layer_repr_healthy(session, colorblending, layer_property, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=2) + find = mocker.patch.object(session, "_find_image_view_order", return_value=2) + layer_property("file_id", 42) layer_property("file_name", "layer1.fits") r = repr(Layer(colorblending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " "file_name='layer1.fits')" ) + find.assert_called_once_with(ImageType.FRAME, 42) -def test_layer_repr_closed_when_parent_missing(session, colorblending, mocker): +def test_layer_repr_closed_when_frame_not_in_image_list( + session, colorblending, layer_property, mocker +): + layer_property("file_id", 42) mocker.patch.object( session, "_find_image_view_order", @@ -93,11 +98,27 @@ def test_layer_repr_closed_when_parent_missing(session, colorblending, mocker): def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): + mocker.patch( + "carta.colorblending.Layer.file_id", + new_callable=mocker.PropertyMock, + side_effect=CartaActionFailed("frame is gone"), + ) + r = repr(Layer(colorblending, 3)) + assert r == ( + "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "layer_id=3)" + ) + + +def test_layer_repr_closed_when_file_name_read_fails( + session, colorblending, layer_property, mocker +): mocker.patch.object(session, "_find_image_view_order", return_value=2) + layer_property("file_id", 42) mocker.patch( "carta.colorblending.Layer.file_name", new_callable=mocker.PropertyMock, - side_effect=CartaActionFailed("frame is gone"), + side_effect=CartaActionFailed("file_name read failed"), ) r = repr(Layer(colorblending, 3)) assert r == ( @@ -116,6 +137,26 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") +def test_layer_image_view_order(session, colorblending, layer_property, mocker): + find = mocker.patch.object(session, "_find_image_view_order", return_value=7) + layer_property("file_id", 42) + assert Layer(colorblending, 3).image_view_order == 7 + find.assert_called_once_with(ImageType.FRAME, 42) + + +def test_layer_image_view_order_raises_when_frame_not_in_image_list( + session, colorblending, layer_property, mocker +): + layer_property("file_id", 42) + mocker.patch.object( + session, + "_find_image_view_order", + side_effect=RuntimeError("not in image list"), + ) + with pytest.raises(RuntimeError): + Layer(colorblending, 3).image_view_order + + @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) From 68f6b271adbd33e6894663fc7b0e4fe792f1dd36 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 22:19:09 +0800 Subject: [PATCH 48/95] Set default colormap set based on number of images for create_color_blending --- carta/session.py | 7 ++++++- tests/test_session.py | 23 ++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/carta/session.py b/carta/session.py index 1be401b..3b7c64b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -690,7 +690,12 @@ def create_color_blending(self, images): :obj:`carta.colorblending.ColorBlending` The new color blending object. """ - return ColorBlending.from_images(self, images) + cb = ColorBlending.from_images(self, images) + if len(images) <= 3: + cb.set_colormap_set(ColormapSet.RGB) + else: + cb.set_colormap_set(ColormapSet.RAINBOW) + return cb def active_frame(self): """Return the currently active image. diff --git a/tests/test_session.py b/tests/test_session.py index 3baaf05..99b759e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -234,15 +234,24 @@ def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, assert result is fake_cb -def test_create_color_blending_delegates_to_from_images(session, mocker): - img0 = Image(session, 100) - img1 = Image(session, 200) +@pytest.mark.parametrize("image_count,expected_colormap_set", [ + # <= 3 images -> RGB + (1, ColormapSet.RGB), + (2, ColormapSet.RGB), + (3, ColormapSet.RGB), + # > 3 images -> RAINBOW + (4, ColormapSet.RAINBOW), +]) +def test_create_color_blending_delegates_to_from_images(session, mocker, image_count, expected_colormap_set): + images = [Image(session, 100 + i) for i in range(image_count)] + fake_cb = mocker.MagicMock(name="ColorBlending") mock_from_images = mocker.patch.object( - ColorBlending, "from_images", return_value="CB" + ColorBlending, "from_images", return_value=fake_cb ) - result = session.create_color_blending([img0, img1]) - mock_from_images.assert_called_once_with(session, [img0, img1]) - assert result == "CB" + result = session.create_color_blending(images) + mock_from_images.assert_called_once_with(session, images) + fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) + assert result is fake_cb # OPENING IMAGES From 57bdb556d4fa0dd5e6cc2abef4f5131ed8d7e6ae Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 20 Apr 2026 22:33:26 +0800 Subject: [PATCH 49/95] Add documentation for image list inspection and update color blending workflow examples --- docs/source/images/image_list.jpg | Bin 0 -> 72264 bytes docs/source/quickstart.rst | 98 +++++++++++++++++------------- 2 files changed, 55 insertions(+), 43 deletions(-) create mode 100644 docs/source/images/image_list.jpg diff --git a/docs/source/images/image_list.jpg b/docs/source/images/image_list.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4a12a2bcea92d7e90a96ad0b5a27fd1a7569024d GIT binary patch literal 72264 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 literal 0 HcmV?d00001 diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 52d5eb3..1cf50e8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -178,6 +178,43 @@ Helper methods on the session object open images in the frontend and return imag 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) + + # Open multiple images in one call + img0, img1, img2 = session.open_images([ + "data/hdf5/first_file.hdf5", + "data/fits/second_file.fits", + "data/fits/third_file.fits", + ]) + +Inspecting the list of open images +---------------------------------- + +The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.colorblending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. + +.. figure:: images/image_list.jpg + :alt: CARTA frontend image list panel showing frame-backed images and a color blending entry. + :align: center + + The frontend image list panel. Each row corresponds to an item returned by :obj:`carta.session.Session.image_list`, and its position in the list is the item's ``image_view_order``. + +.. code-block:: python + + from carta.image import Image + from carta.colorblending import ColorBlending + + # All open image-view items, in display order + items = session.image_list() + + # Filter by type if needed + images = [i for i in items if isinstance(i, Image)] + color_blendings = [i for i in items if isinstance(i, ColorBlending)] + + # Every image-view item exposes its current image-view order + print(img0.image_view_order) + + # Retrieve a specific item by image view order + img = session.get_image(image_view_order=0) + cb = session.get_image(image_view_order=1) Changing image properties ------------------------- @@ -234,55 +271,26 @@ 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: +The session object provides two convenience methods which create a color blending image and apply a sensible default colormap set (``RGB`` for three layers or fewer, ``RAINBOW`` for more): .. code-block:: python - from carta.colorblending import ColorBlending - from carta.constants import Colormap, ColormapSet - - # Make a color blending object - # Warning: setting `append=False` will close any existing images - # Note: The base layer (index = 0) cannot be deleted or moved. files = [ "data/hdf5/first_file.hdf5", "data/fits/second_file.fits", "data/fits/third_file.fits", ] - cb = ColorBlending.from_files(session, files, append=False) - -Create a color blending object from a list of images: -.. code-block:: python - - from carta.colorblending import ColorBlending - from carta.constants import Colormap, ColormapSet + # Open the files and combine them into a new color blending image + # Warning: setting `append=False` will close any existing images + cb = session.open_as_color_blending(files, append=False) - # Make a color blending object - # Warning: This will break the current spatial matching and + # Combine already-open images into a new color blending image + # Warning: this will break the current spatial matching and # use the first image as the spatial reference - # Note: The base layer (index = 0) cannot be deleted or moved. - cb = ColorBlending.from_images(session, [img0, img1, img2]) + cb = session.create_color_blending([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: +Manipulate properties of the color blending object and the underlying layers: .. code-block:: python @@ -327,7 +335,10 @@ Manipulate properties of the color blending object and the underlying images: # Set zoom level cb.set_zoom_level(2) - # Set the color blending object as the active frame + # Get the current image-view order of the color blending image + print(cb.image_view_order) + + # Set the color blending object as the active image-view item cb.make_active() # Set contour visibility @@ -338,8 +349,9 @@ Manipulate properties of the color blending object and the underlying images: cb.close() .. note:: - If you need to change the layer order involving the base layer (index = 0), - close the current color blending object and create a new one. + The base layer (index = 0) cannot be deleted or moved. If you need to + change the layer order involving the base layer, close the current color + blending object and create a new one. Saving or displaying an image ----------------------------- @@ -369,9 +381,9 @@ Closing images .. code-block:: python - # Close all images open in the session - for img in session.image_list(): - img.close() + # Close all image-view items open in the session + for item in session.image_list(): + item.close() Closing the session ------------------- From e7a8370dcbc879632828eb83e8b610e4d065c052 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 21 Apr 2026 21:37:45 +0800 Subject: [PATCH 50/95] Add validation to prevent rebasing existing color blendings when creating new ones --- carta/colorblending.py | 27 ++++++++++------ carta/session.py | 58 +++++++++++++++++++++++++++++++--- docs/source/quickstart.rst | 21 ++++++++++--- tests/test_colorblending.py | 26 +++++++++++++-- tests/test_session.py | 63 +++++++++++++++++++++++++++++++++++-- 5 files changed, 172 insertions(+), 23 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 0647f2a..b652d1e 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -235,9 +235,13 @@ def from_image_view_order(cls, session, image_view_order): def from_images(cls, session, images): """Create a color blending object from a list of images. - Side effect: this overwrites the session-wide spatial reference - to ``images[0]`` and spatially matches each of ``images[1:]`` to - it. + If color blending images are already open, ``images[0]`` must + already be the current spatial reference. Rebasing existing + color blendings is rejected. + + Side effect: on success, this ensures that ``images[0]`` is the + current spatial reference and that each of ``images[1:]`` is + spatially matched to it. Parameters ---------- @@ -257,11 +261,15 @@ def from_images(cls, session, images): CartaValidationFailed If ``images`` is empty or contains a non-:obj:`carta.image.Image` value. + ValueError + If color blendings are already open and ``images[0]`` is not the + current spatial reference. CartaActionFailed If the atomic frontend action fails. In practice this happens when the input contains a stale/closed frame or exceeds the frontend's layer-count limit. """ + session._validate_color_blending_base(images[0].file_id) result = session.call_action( "imageViewConfigStore.createColorBlendingFromFrames", [image._frame for image in images], @@ -275,26 +283,27 @@ def from_images(cls, session, images): return cls(session, result["id"]) @classmethod - def from_files(cls, session, files, append=False): + def from_files(cls, session, files): """Create a color blending object from a list of files. + This helper always opens the files with ``append=False``, which + closes any currently open images before opening ``files``, + because the frontend does not support creating a color blending + in append mode. + 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) + images = session.open_images(files, append=False) return cls.from_images(session, images) @property diff --git a/carta/session.py b/carta/session.py index 3b7c64b..e5395cc 100644 --- a/carta/session.py +++ b/carta/session.py @@ -566,6 +566,38 @@ def _find_image_view_order(self, image_type, stable_id): f"{stable_id} in the image list." ) + def _validate_color_blending_base(self, base_file_id): + """Reject color-blending creation that would rebase existing blendings. + + Parameters + ---------- + base_file_id : integer + The file id of the requested base image. + + Raises + ------ + ValueError + If one or more color blending images are already open and the + requested base image is not the current spatial reference. + """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + has_open_color_blending = any( + entry["type"] == ImageType.COLOR_BLENDING for entry in summary + ) + if not has_open_color_blending: + return + + current_spatial_reference_file_id = self.get_value("spatialReference.id") + if current_spatial_reference_file_id != base_file_id: + raise ValueError( + "Cannot create a color blending with a different base image " + "while color blendings are already open. images[0] must be " + "the current spatial reference. Call " + "images[0].make_spatial_reference() and retry " + f"(requested base file_id={base_file_id}, current spatial " + f"reference file_id={current_spatial_reference_file_id})." + ) + def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. @@ -654,23 +686,33 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No f"No color blending with color_blending_id={color_blending_id} is open." ) - @validate(IterableOf(String()), Boolean()) - def open_as_color_blending(self, files, append=False): + @validate(IterableOf(String())) + def open_as_color_blending(self, files): """Open files and combine them into a new color blending image. + This helper always opens the files with ``append=False``, which + closes any currently open images before opening ``files``, + because the frontend does not support creating a color blending + in append mode. + Parameters ---------- files : {0} The files to be blended. - append : {1} - 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` The new color blending object. + + Raises + ------ + ValueError + If color blendings are already open and the first opened file does + not become the current spatial reference. This validation happens + after the files are opened. """ - cb = ColorBlending.from_files(self, files, append=append) + cb = ColorBlending.from_files(self, files) if len(files) <= 3: cb.set_colormap_set(ColormapSet.RGB) else: @@ -689,6 +731,12 @@ def create_color_blending(self, images): ------- :obj:`carta.colorblending.ColorBlending` The new color blending object. + + Raises + ------ + ValueError + If color blendings are already open and ``images[0]`` is not the + current spatial reference. """ cb = ColorBlending.from_images(self, images) if len(images) <= 3: diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 1cf50e8..8fb9d1e 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -282,14 +282,27 @@ The session object provides two convenience methods which create a color blendin ] # Open the files and combine them into a new color blending image - # Warning: setting `append=False` will close any existing images - cb = session.open_as_color_blending(files, append=False) + # Warning: this always opens files with append=False, + # so any existing images will be closed first + cb = session.open_as_color_blending(files) # Combine already-open images into a new color blending image - # Warning: this will break the current spatial matching and - # use the first image as the spatial reference + # Set the first image as the base layer. + # If color blendings are already open, img0 must already be the + # current spatial reference; otherwise make it the reference first. + img0.make_spatial_reference() cb = session.create_color_blending([img0, img1, img2]) +.. note:: + ``session.open_as_color_blending(files)`` always closes any currently + open images before opening ``files``. + + ``session.create_color_blending(images)`` treats ``images[0]`` as the + base layer. If color blending images are already open, ``images[0]`` + must already be the current spatial reference. To switch to a new base + image first, call ``images[0].make_spatial_reference()`` before creating + the color blending. + Manipulate properties of the color blending object and the underlying layers: .. code-block:: python diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 5e01400..8ed9115 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -637,6 +637,7 @@ def test_colorblending_from_images_success(session, mocker): img1 = Image(session, 200) img2 = Image(session, 300) + validate = mocker.patch.object(session, "_validate_color_blending_base") call_action = mocker.patch.object(session, "call_action", return_value={"id": 123}) cb = ColorBlending.from_images(session, [img0, img1, img2]) @@ -645,6 +646,7 @@ def test_colorblending_from_images_success(session, mocker): assert cb.color_blending_id == 123 assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" + validate.assert_called_once_with(100) call_action.assert_called_once_with( "imageViewConfigStore.createColorBlendingFromFrames", [img0._frame, img1._frame, img2._frame], @@ -655,11 +657,31 @@ def test_colorblending_from_images_null_return_raises_action_failed( session, mocker ): img0 = Image(session, 100) + validate = mocker.patch.object(session, "_validate_color_blending_base") mocker.patch.object(session, "call_action", return_value=None) with pytest.raises(CartaActionFailed): ColorBlending.from_images(session, [img0]) + validate.assert_called_once_with(100) + + +def test_colorblending_from_images_propagates_base_validation_error( + session, mocker +): + img0 = Image(session, 100) + call_action = mocker.patch.object(session, "call_action") + mocker.patch.object( + session, + "_validate_color_blending_base", + side_effect=ValueError(), + ) + + with pytest.raises(ValueError): + ColorBlending.from_images(session, [img0]) + + call_action.assert_not_called() + def test_colorblending_from_images_rejects_empty_list(session, mocker): call_action = mocker.patch.object(session, "call_action") @@ -688,7 +710,7 @@ def test_colorblending_from_files(session, mocker): 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) + out = ColorBlending.from_files(session, ["a.fits", "b.fits"]) + mock_open_images.assert_called_with(["a.fits", "b.fits"], append=False) mock_from_images.assert_called_once() assert out == "CB" diff --git a/tests/test_session.py b/tests/test_session.py index 99b759e..6cc2fe6 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import Macro +from carta.util import CartaValidationFailed, Macro from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -212,6 +212,63 @@ def test_get_image_single_round_trip(session, summary): assert call.args == ("imageViewConfigStore.imageListSummary",) +# session._validate_color_blending_base + + +def test_validate_color_blending_base_allows_when_no_open_color_blendings( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.FRAME, "id": 20}, + ] + + session._validate_color_blending_base(10) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_validate_color_blending_base_allows_current_spatial_reference( + session, get_value +): + get_value.side_effect = [ + [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ], + 10, + ] + + session._validate_color_blending_base(10) + + assert [call.args for call in get_value.call_args_list] == [ + ("imageViewConfigStore.imageListSummary",), + ("spatialReference.id",), + ] + + +def test_validate_color_blending_base_rejects_rebasing_existing_color_blendings( + session, get_value +): + get_value.side_effect = [ + [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ], + 20, + ] + + with pytest.raises(ValueError): + session._validate_color_blending_base(10) + + assert [call.args for call in get_value.call_args_list] == [ + ("imageViewConfigStore.imageListSummary",), + ("spatialReference.id",), + ] + + # open_as_color_blending / create_color_blending @@ -228,8 +285,8 @@ def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, mock_from_files = mocker.patch.object( ColorBlending, "from_files", return_value=fake_cb ) - result = session.open_as_color_blending(files, append=True) - mock_from_files.assert_called_once_with(session, files, append=True) + result = session.open_as_color_blending(files) + mock_from_files.assert_called_once_with(session, files) fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) assert result is fake_cb From e13fd3af80ffe88e931b52ad067d5c0bb2184a03 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 12:19:43 +0800 Subject: [PATCH 51/95] Rename active_frame to active_image and add support for ColorBlending type --- carta/session.py | 34 ++++++++++++++++++++++++++-------- tests/test_session.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/carta/session.py b/carta/session.py index e5395cc..761e823 100644 --- a/carta/session.py +++ b/carta/session.py @@ -745,16 +745,34 @@ def create_color_blending(self, images): cb.set_colormap_set(ColormapSet.RAINBOW) return cb - def active_frame(self): - """Return the currently active image. + def active_image(self): + """Return the currently active image-view item. + + This is the frame-backed image or color blending image that is + currently active in the viewer. Returns ------- - :obj:`carta.image.Image` - The currently active image. - """ - file_id = self.get_value("activeFrame.frameInfo.fileId") - return Image(self, file_id) + :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` + The currently active image-view item. + + Raises + ------ + NotImplementedError + If the active image is of a type that is not yet wrapped on + the Python side. + """ + active_type = self.get_value("activeImage.type") + active_id = self.get_value("activeImage.store.id") + if active_type == ImageType.FRAME: + return Image(self, active_id) + if active_type == ImageType.COLOR_BLENDING: + return ColorBlending(self, active_id) + raise NotImplementedError( + f"active_image encountered an unsupported image-view type " + f"{active_type!r}; only Image (FRAME) and ColorBlending " + "(COLOR_BLENDING) entries are currently wrapped." + ) def image_by_id(self, image_id): """Return an image object with the specified ID. @@ -845,7 +863,7 @@ def set_cursor(self, x, y): The Y position. """ - self.active_frame().regions.call_action("updateCursorRegionPosition", Pt(x, y)) + self.call_action("activeFrame.regionSet.updateCursorRegionPosition", Pt(x, y)) # SAVE IMAGE diff --git a/tests/test_session.py b/tests/test_session.py index 6cc2fe6..07f88cf 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -212,6 +212,39 @@ def test_get_image_single_round_trip(session, summary): assert call.args == ("imageViewConfigStore.imageListSummary",) +# session.active_image + + +def test_active_image_returns_image_when_frame_active(session, get_value): + get_value.side_effect = [ImageType.FRAME, 12] + active = session.active_image() + assert isinstance(active, Image) + assert active.file_id == 12 + assert [call.args for call in get_value.call_args_list] == [ + ("activeImage.type",), + ("activeImage.store.id",), + ] + + +def test_active_image_returns_color_blending_when_color_blending_active( + session, get_value +): + get_value.side_effect = [ImageType.COLOR_BLENDING, 3] + active = session.active_image() + assert isinstance(active, ColorBlending) + assert active.color_blending_id == 3 + assert [call.args for call in get_value.call_args_list] == [ + ("activeImage.type",), + ("activeImage.store.id",), + ] + + +def test_active_image_raises_on_unsupported_type(session, get_value): + get_value.side_effect = [ImageType.PV_PREVIEW, -2] + with pytest.raises(NotImplementedError): + session.active_image() + + # session._validate_color_blending_base From b47334b260a9579a7b71c3dd0ea2d34448c83a65 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 12:26:08 +0800 Subject: [PATCH 52/95] Rename get_image to image_by_id and update signature to use keyword arguments for image identification --- carta/session.py | 23 +++-------------- carta/wcs_overlay.py | 2 +- docs/source/quickstart.rst | 4 +-- tests/test_session.py | 52 +++++++++++++++++++------------------- 4 files changed, 32 insertions(+), 49 deletions(-) diff --git a/carta/session.py b/carta/session.py index 761e823..ea7b442 100644 --- a/carta/session.py +++ b/carta/session.py @@ -598,7 +598,7 @@ def _validate_color_blending_base(self, base_file_id): f"reference file_id={current_spatial_reference_file_id})." ) - def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=None): + def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. Parameters @@ -640,7 +640,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No given = [key for key, val in provided.items() if val is not None] if len(given) != 1: raise ValueError( - "get_image requires exactly one of the keyword arguments " + "image_by_id requires exactly one of the keyword arguments " "`image_view_order`, `file_id`, or `color_blending_id`; " f"got {len(given)}." ) @@ -661,7 +661,7 @@ def get_image(self, *, image_view_order=None, file_id=None, color_blending_id=No if entry_type == ImageType.COLOR_BLENDING: return ColorBlending(self, entry_id) raise NotImplementedError( - f"get_image encountered an unsupported image-view entry " + f"image_by_id encountered an unsupported image-view entry " f"at order {image_view_order} with type {entry_type!r}; " "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " "entries are currently wrapped." @@ -774,23 +774,6 @@ def active_image(self): "(COLOR_BLENDING) entries are currently wrapped." ) - def image_by_id(self, image_id): - """Return an image object with the specified ID. - - This is a helper function which constructs a :obj:`carta.image.Image` object with the specified ID, without checking whether an image with that ID is currently open. It is the caller's responsibility to ensure this. - - Parameters - ---------- - image_id : integer - The ID of the image to return. - - Returns - ------- - :obj:`carta.image.Image` - The image with the specified ID. - """ - return Image(self, image_id) - def clear_spatial_reference(self): """Clear the spatial reference.""" self.call_action("clearSpatialReference") diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index 6d26829..a46a440 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -440,7 +440,7 @@ def _images(self, image_ids=None): from .image import Image if image_ids is None: return [img for img in self.session.image_list() if isinstance(img, Image)] - return [self.session.image_by_id(image_id) for image_id in image_ids] + return [self.session.image_by_id(file_id=image_id) for image_id in image_ids] def _get_image_wcs_properties(self, image_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 8fb9d1e..1f54299 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -213,8 +213,8 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba print(img0.image_view_order) # Retrieve a specific item by image view order - img = session.get_image(image_view_order=0) - cb = session.get_image(image_view_order=1) + img = session.image_by_id(image_view_order=0) + cb = session.image_by_id(image_view_order=1) Changing image properties ------------------------- diff --git a/tests/test_session.py b/tests/test_session.py index 07f88cf..8ecbcbe 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -129,7 +129,7 @@ def test_find_image_view_order_raises_when_missing(session, get_value): session._find_image_view_order(ImageType.FRAME, 99) -# session.get_image +# session.image_by_id @pytest.fixture @@ -142,71 +142,71 @@ def summary(get_value): return get_value -def test_get_image_requires_exactly_one_keyword(session, get_value): +def test_image_by_id_requires_exactly_one_keyword(session, get_value): # Zero keywords -> ValueError with all three names listed. with pytest.raises(ValueError) as e: - session.get_image() + session.image_by_id() for name in ("image_view_order", "file_id", "color_blending_id"): assert name in str(e.value) # Multiple keywords -> ValueError. with pytest.raises(ValueError): - session.get_image(file_id=1, color_blending_id=2) + session.image_by_id(file_id=1, color_blending_id=2) -def test_get_image_rejects_positional(session): +def test_image_by_id_rejects_positional(session): with pytest.raises(TypeError): - session.get_image(0) + session.image_by_id(0) -def test_get_image_by_image_view_order(session, summary): - img = session.get_image(image_view_order=0) +def test_image_by_id_by_image_view_order(session, summary): + img = session.image_by_id(image_view_order=0) assert isinstance(img, Image) assert img.file_id == 10 - cb = session.get_image(image_view_order=1) + cb = session.image_by_id(image_view_order=1) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 - img2 = session.get_image(image_view_order=2) + img2 = session.image_by_id(image_view_order=2) assert isinstance(img2, Image) assert img2.file_id == 20 -def test_get_image_by_image_view_order_out_of_range(session, summary): +def test_image_by_id_by_image_view_order_out_of_range(session, summary): with pytest.raises(IndexError): - session.get_image(image_view_order=99) + session.image_by_id(image_view_order=99) -def test_get_image_by_file_id(session, summary): - img = session.get_image(file_id=20) +def test_image_by_id_by_file_id(session, summary): + img = session.image_by_id(file_id=20) assert isinstance(img, Image) assert img.file_id == 20 -def test_get_image_by_file_id_no_cross_type_fallback(session, summary): +def test_image_by_id_by_file_id_no_cross_type_fallback(session, summary): # The summary contains a COLOR_BLENDING entry with id=7, but no FRAME - # with that id, so get_image(file_id=7) must raise. + # with that id, so image_by_id(file_id=7) must raise. with pytest.raises(RuntimeError): - session.get_image(file_id=7) + session.image_by_id(file_id=7) -def test_get_image_by_color_blending_id(session, summary): - cb = session.get_image(color_blending_id=7) +def test_image_by_id_by_color_blending_id(session, summary): + cb = session.image_by_id(color_blending_id=7) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 -def test_get_image_by_color_blending_id_no_cross_type_fallback(session, summary): +def test_image_by_id_by_color_blending_id_no_cross_type_fallback(session, summary): # The summary contains a FRAME with id=10, but no COLOR_BLENDING with - # that id, so get_image(color_blending_id=10) must raise. + # that id, so image_by_id(color_blending_id=10) must raise. with pytest.raises(RuntimeError): - session.get_image(color_blending_id=10) + session.image_by_id(color_blending_id=10) -def test_get_image_single_round_trip(session, summary): - session.get_image(image_view_order=0) - session.get_image(file_id=10) - session.get_image(color_blending_id=7) +def test_image_by_id_single_round_trip(session, summary): + session.image_by_id(image_view_order=0) + session.image_by_id(file_id=10) + session.image_by_id(color_blending_id=7) assert summary.call_count == 3 for call in summary.call_args_list: assert call.args == ("imageViewConfigStore.imageListSummary",) From a4630debc232fe50ab6c9ec8906371e0e603cb7d Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 15:15:08 +0800 Subject: [PATCH 53/95] Update set_cursor to use activeFrame.setCursorPosition action and remove outdated TODO comment --- carta/session.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/carta/session.py b/carta/session.py index ea7b442..2df7d27 100644 --- a/carta/session.py +++ b/carta/session.py @@ -836,8 +836,6 @@ def set_viewer_grid(self, rows, columns, grid_mode=GridMode.FIXED): def set_cursor(self, x, y): """Set the curson position. - TODO: this is a precursor to making z-profiles available, but currently the relevant functionality is not exposed by the frontend. There is also a frontend issue which is preventing the cursor from being updated correctly (it is updated only in the profiles). - Parameters ---------- x : {0} @@ -846,7 +844,7 @@ def set_cursor(self, x, y): The Y position. """ - self.call_action("activeFrame.regionSet.updateCursorRegionPosition", Pt(x, y)) + self.call_action("activeFrame.setCursorPosition", Pt(x, y)) # SAVE IMAGE From c6e8a54e78cb0fc546b4813f902112c88c4a2baa Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 16:36:48 +0800 Subject: [PATCH 54/95] Remove ColorBlending factory methods and simplify create_color_blending to use frontend's createColorBlending action --- carta/colorblending.py | 117 +---------------------------- carta/session.py | 87 +++++++--------------- docs/source/quickstart.rst | 29 ++++---- tests/test_colorblending.py | 127 -------------------------------- tests/test_session.py | 143 +++++++++++++++--------------------- 5 files changed, 102 insertions(+), 401 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index b652d1e..8af7ec7 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -3,9 +3,8 @@ from .constants import Colormap, ColormapSet, ImageType from .image import Image from .image_base import ImageBase -from .util import BasePathMixin, CartaActionFailed, CartaScriptingException, Macro +from .util import BasePathMixin, CartaScriptingException, Macro from .validation import ( - Any, Boolean, Constant, Coordinate, @@ -192,120 +191,6 @@ def __init__(self, session, color_blending_id): def _stable_id(self): return self.color_blending_id - # FACTORIES - - @classmethod - def from_image_view_order(cls, session, image_view_order): - """Create a color blending object from an image view order. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - image_view_order : integer - The image-view order of the color blending image. - - Returns - ------- - :obj:`carta.colorblending.ColorBlending` - A new color blending object. - - Raises - ------ - ValueError - If the entry at the given image-view order is not a color blending image. - IndexError - If ``image_view_order`` is out of range. - """ - summary = session.get_value("imageViewConfigStore.imageListSummary") - if image_view_order < 0 or image_view_order >= len(summary): - raise IndexError( - f"image_view_order {image_view_order} is out of range for " - f"an image list of length {len(summary)}." - ) - entry = summary[image_view_order] - if entry["type"] != ImageType.COLOR_BLENDING: - raise ValueError( - "image_view_order does not refer to a color blending image." - ) - return cls(session, entry["id"]) - - @classmethod - @validate(Any(), IterableOf(InstanceOf(Image), min_size=1)) - def from_images(cls, session, images): - """Create a color blending object from a list of images. - - If color blending images are already open, ``images[0]`` must - already be the current spatial reference. Rebasing existing - color blendings is rejected. - - Side effect: on success, this ensures that ``images[0]`` is the - current spatial reference and that each of ``images[1:]`` is - spatially matched to it. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - images : {1} - The images to be blended. Must be non-empty. The first entry - becomes the base layer. - - Returns - ------- - :obj:`carta.colorblending.ColorBlending` - A new color blending object. - - Raises - ------ - CartaValidationFailed - If ``images`` is empty or contains a non-:obj:`carta.image.Image` - value. - ValueError - If color blendings are already open and ``images[0]`` is not the - current spatial reference. - CartaActionFailed - If the atomic frontend action fails. In practice this - happens when the input contains a stale/closed frame or - exceeds the frontend's layer-count limit. - """ - session._validate_color_blending_base(images[0].file_id) - result = session.call_action( - "imageViewConfigStore.createColorBlendingFromFrames", - [image._frame for image in images], - ) - if result is None: - raise CartaActionFailed( - "Failed to create color blending: the frontend returned " - "null. This indicates a stale frame or a layer-count " - "limit exceeded." - ) - return cls(session, result["id"]) - - @classmethod - def from_files(cls, session, files): - """Create a color blending object from a list of files. - - This helper always opens the files with ``append=False``, which - closes any currently open images before opening ``files``, - because the frontend does not support creating a color blending - in append mode. - - Parameters - ---------- - session : :obj:`carta.session.Session` - The session object. - files : list of string - The files to be blended. - - Returns - ------- - :obj:`carta.colorblending.ColorBlending` - A new color blending object. - """ - images = session.open_images(files, append=False) - return cls.from_images(session, images) - @property def image_view_order(self): """The current index of this color blending in image list. diff --git a/carta/session.py b/carta/session.py index 2df7d27..a938f23 100644 --- a/carta/session.py +++ b/carta/session.py @@ -14,7 +14,7 @@ from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -566,38 +566,6 @@ def _find_image_view_order(self, image_type, stable_id): f"{stable_id} in the image list." ) - def _validate_color_blending_base(self, base_file_id): - """Reject color-blending creation that would rebase existing blendings. - - Parameters - ---------- - base_file_id : integer - The file id of the requested base image. - - Raises - ------ - ValueError - If one or more color blending images are already open and the - requested base image is not the current spatial reference. - """ - summary = self.get_value("imageViewConfigStore.imageListSummary") - has_open_color_blending = any( - entry["type"] == ImageType.COLOR_BLENDING for entry in summary - ) - if not has_open_color_blending: - return - - current_spatial_reference_file_id = self.get_value("spatialReference.id") - if current_spatial_reference_file_id != base_file_id: - raise ValueError( - "Cannot create a color blending with a different base image " - "while color blendings are already open. images[0] must be " - "the current spatial reference. Call " - "images[0].make_spatial_reference() and retry " - f"(requested base file_id={base_file_id}, current spatial " - f"reference file_id={current_spatial_reference_file_id})." - ) - def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. @@ -691,9 +659,10 @@ def open_as_color_blending(self, files): """Open files and combine them into a new color blending image. This helper always opens the files with ``append=False``, which - closes any currently open images before opening ``files``, - because the frontend does not support creating a color blending - in append mode. + closes any currently open images before opening ``files``. It + then makes the first opened image the spatial reference, enables + spatial matching for the remaining opened images, and calls + :obj:`create_color_blending`. Parameters ---------- @@ -704,28 +673,17 @@ def open_as_color_blending(self, files): ------- :obj:`carta.colorblending.ColorBlending` The new color blending object. - - Raises - ------ - ValueError - If color blendings are already open and the first opened file does - not become the current spatial reference. This validation happens - after the files are opened. """ - cb = ColorBlending.from_files(self, files) - if len(files) <= 3: - cb.set_colormap_set(ColormapSet.RGB) - else: - cb.set_colormap_set(ColormapSet.RAINBOW) + images = self.open_images(files, append=False) + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + cb = self.create_color_blending() return cb - def create_color_blending(self, images): - """Combine already-open images into a new color blending image. - - Parameters - ---------- - images : list of :obj:`carta.image.Image` - The images to be blended. The first entry becomes the base layer. + def create_color_blending(self): + """Create a new color blending from the current spatial reference + and its currently spatially matched frames. Returns ------- @@ -734,12 +692,21 @@ def create_color_blending(self, images): Raises ------ - ValueError - If color blendings are already open and ``images[0]`` is not the - current spatial reference. + CartaActionFailed + If no frames are open or the frontend could not create the + color blending image. """ - cb = ColorBlending.from_images(self, images) - if len(images) <= 3: + frame_length = self.get_value("frames.length") + if frame_length <= 0: + raise CartaActionFailed("No frames are open") + + color_blending_id = self.call_action( + "imageViewConfigStore.createColorBlending", + return_path="id", + ) + cb = ColorBlending(self, color_blending_id) + + if frame_length <= 3: cb.set_colormap_set(ColormapSet.RGB) else: cb.set_colormap_set(ColormapSet.RAINBOW) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 1f54299..ba6ff36 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -281,27 +281,26 @@ The session object provides two convenience methods which create a color blendin "data/fits/third_file.fits", ] - # Open the files and combine them into a new color blending image - # Warning: this always opens files with append=False, - # so any existing images will be closed first + # Open the files and combine them into a new color blending image. + # This closes any currently open images, makes the first opened + # image the spatial reference, and spatially matches the remaining + # opened images to it. cb = session.open_as_color_blending(files) - # Combine already-open images into a new color blending image - # Set the first image as the base layer. - # If color blendings are already open, img0 must already be the - # current spatial reference; otherwise make it the reference first. + # Create a new color blending from the current spatial reference + # and its currently spatially matched frames. + # Set the desired base image as the current spatial reference and + # enable spatial matching for the other layers first. img0.make_spatial_reference() - cb = session.create_color_blending([img0, img1, img2]) + img1.set_spatial_matching(True) + img2.set_spatial_matching(True) + cb = session.create_color_blending() .. note:: ``session.open_as_color_blending(files)`` always closes any currently - open images before opening ``files``. - - ``session.create_color_blending(images)`` treats ``images[0]`` as the - base layer. If color blending images are already open, ``images[0]`` - must already be the current spatial reference. To switch to a new base - image first, call ``images[0].make_spatial_reference()`` before creating - the color blending. + open images before opening ``files``. It then makes the first opened + image the spatial reference and spatially matches the remaining + opened images to it before creating the color blending. Manipulate properties of the color blending object and the underlying layers: diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 8ed9115..24972f5 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -587,130 +587,3 @@ def test_colorblending_close(session, colorblending, session_call_action): session_call_action.assert_called_with( "imageViewConfigStore.removeColorBlending", colorblending._frame ) - - -# CREATION HELPERS - - -def test_colorblending_from_image_view_order(session, session_get_value): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.FRAME, "id": 20}, - {"type": ImageType.COLOR_BLENDING, "id": 17}, - ] - - cb = ColorBlending.from_image_view_order(session, 2) - - assert isinstance(cb, ColorBlending) - assert cb.color_blending_id == 17 - expected = "imageViewConfigStore.colorBlendingImageMap[17]" - assert cb._base_path == expected - session_get_value.assert_called_once_with( - "imageViewConfigStore.imageListSummary" - ) - - -def test_colorblending_from_image_view_order_rejects_non_color_blending( - session, session_get_value, mocker -): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - ] - - with pytest.raises( - ValueError, - match="image_view_order does not refer to a color blending image.", - ): - ColorBlending.from_image_view_order(session, 0) - - -def test_colorblending_from_image_view_order_out_of_range( - session, session_get_value -): - session_get_value.return_value = [] - with pytest.raises(IndexError): - ColorBlending.from_image_view_order(session, 0) - - -def test_colorblending_from_images_success(session, mocker): - img0 = Image(session, 100) - img1 = Image(session, 200) - img2 = Image(session, 300) - - validate = mocker.patch.object(session, "_validate_color_blending_base") - call_action = mocker.patch.object(session, "call_action", return_value={"id": 123}) - - cb = ColorBlending.from_images(session, [img0, img1, img2]) - - assert isinstance(cb, ColorBlending) - assert cb.color_blending_id == 123 - assert cb._base_path == "imageViewConfigStore.colorBlendingImageMap[123]" - - validate.assert_called_once_with(100) - call_action.assert_called_once_with( - "imageViewConfigStore.createColorBlendingFromFrames", - [img0._frame, img1._frame, img2._frame], - ) - - -def test_colorblending_from_images_null_return_raises_action_failed( - session, mocker -): - img0 = Image(session, 100) - validate = mocker.patch.object(session, "_validate_color_blending_base") - mocker.patch.object(session, "call_action", return_value=None) - - with pytest.raises(CartaActionFailed): - ColorBlending.from_images(session, [img0]) - - validate.assert_called_once_with(100) - - -def test_colorblending_from_images_propagates_base_validation_error( - session, mocker -): - img0 = Image(session, 100) - call_action = mocker.patch.object(session, "call_action") - mocker.patch.object( - session, - "_validate_color_blending_base", - side_effect=ValueError(), - ) - - with pytest.raises(ValueError): - ColorBlending.from_images(session, [img0]) - - call_action.assert_not_called() - - -def test_colorblending_from_images_rejects_empty_list(session, mocker): - call_action = mocker.patch.object(session, "call_action") - - with pytest.raises(CartaValidationFailed): - ColorBlending.from_images(session, []) - - call_action.assert_not_called() - - -def test_colorblending_from_images_rejects_non_image_element(session, mocker): - call_action = mocker.patch.object(session, "call_action") - - with pytest.raises(CartaValidationFailed): - ColorBlending.from_images(session, ["not-an-image"]) - - call_action.assert_not_called() - - -def test_colorblending_from_files(session, mocker): - mock_open_images = mocker.patch.object( - session, - "open_images", - return_value=[Image(session, 1), Image(session, 2)], - ) - mock_from_images = mocker.patch.object( - ColorBlending, "from_images", return_value="CB" - ) - out = ColorBlending.from_files(session, ["a.fits", "b.fits"]) - mock_open_images.assert_called_with(["a.fits", "b.fits"], append=False) - mock_from_images.assert_called_once() - assert out == "CB" diff --git a/tests/test_session.py b/tests/test_session.py index 8ecbcbe..492e30d 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import CartaValidationFailed, Macro +from carta.util import CartaActionFailed, Macro from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -244,104 +244,81 @@ def test_active_image_raises_on_unsupported_type(session, get_value): with pytest.raises(NotImplementedError): session.active_image() - -# session._validate_color_blending_base - - -def test_validate_color_blending_base_allows_when_no_open_color_blendings( - session, get_value -): - get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.FRAME, "id": 20}, - ] - - session._validate_color_blending_base(10) - - get_value.assert_called_once_with( - "imageViewConfigStore.imageListSummary" - ) - - -def test_validate_color_blending_base_allows_current_spatial_reference( - session, get_value -): - get_value.side_effect = [ - [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - ], - 10, - ] - - session._validate_color_blending_base(10) - - assert [call.args for call in get_value.call_args_list] == [ - ("imageViewConfigStore.imageListSummary",), - ("spatialReference.id",), - ] - - -def test_validate_color_blending_base_rejects_rebasing_existing_color_blendings( - session, get_value -): - get_value.side_effect = [ - [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - ], - 20, - ] - - with pytest.raises(ValueError): - session._validate_color_blending_base(10) - - assert [call.args for call in get_value.call_args_list] == [ - ("imageViewConfigStore.imageListSummary",), - ("spatialReference.id",), - ] - - # open_as_color_blending / create_color_blending -@pytest.mark.parametrize("files,expected_colormap_set", [ - # <= 3 files -> RGB - (["a.fits"], ColormapSet.RGB), - (["a.fits", "b.fits"], ColormapSet.RGB), - (["a.fits", "b.fits", "c.fits"], ColormapSet.RGB), - # > 3 files -> RAINBOW - (["a.fits", "b.fits", "c.fits", "d.fits"], ColormapSet.RAINBOW), +@pytest.mark.parametrize("files", [ + ["a.fits"], + ["a.fits", "b.fits"], + ["a.fits", "b.fits", "c.fits"], + ["a.fits", "b.fits", "c.fits", "d.fits"], ]) -def test_open_as_color_blending_delegates_to_from_files(session, mocker, files, expected_colormap_set): +def test_open_as_color_blending_opens_files_sets_matching_and_creates_blending( + session, mocker, files +): + images = [mocker.MagicMock(name=f"image{i}") for i in range(len(files))] + open_images = mocker.patch.object( + session, + "open_images", + return_value=images, + ) fake_cb = mocker.MagicMock(name="ColorBlending") - mock_from_files = mocker.patch.object( - ColorBlending, "from_files", return_value=fake_cb + create_color_blending = mocker.patch.object( + session, + "create_color_blending", + return_value=fake_cb, ) + result = session.open_as_color_blending(files) - mock_from_files.assert_called_once_with(session, files) - fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) + open_images.assert_called_once_with(files, append=False) + images[0].make_spatial_reference.assert_called_once_with() + images[0].set_spatial_matching.assert_not_called() + for image in images[1:]: + image.set_spatial_matching.assert_called_once_with(True) + image.make_spatial_reference.assert_not_called() + create_color_blending.assert_called_once_with() assert result is fake_cb -@pytest.mark.parametrize("image_count,expected_colormap_set", [ - # <= 3 images -> RGB +@pytest.mark.parametrize("frame_count,expected_colormap_set", [ + # <= 3 open frames -> RGB (1, ColormapSet.RGB), (2, ColormapSet.RGB), (3, ColormapSet.RGB), - # > 3 images -> RAINBOW + # > 3 open frames -> RAINBOW (4, ColormapSet.RAINBOW), ]) -def test_create_color_blending_delegates_to_from_images(session, mocker, image_count, expected_colormap_set): - images = [Image(session, 100 + i) for i in range(image_count)] - fake_cb = mocker.MagicMock(name="ColorBlending") - mock_from_images = mocker.patch.object( - ColorBlending, "from_images", return_value=fake_cb +def test_create_color_blending_calls_frontend_create_action(session, mocker, frame_count, expected_colormap_set): + get_value = mocker.patch.object(session, "get_value", return_value=frame_count) + call_action = mocker.patch.object( + session, + "call_action", + return_value=123, ) - result = session.create_color_blending(images) - mock_from_images.assert_called_once_with(session, images) - fake_cb.set_colormap_set.assert_called_once_with(expected_colormap_set) - assert result is fake_cb + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + result = session.create_color_blending() + get_value.assert_called_once_with("frames.length") + call_action.assert_called_once_with( + "imageViewConfigStore.createColorBlending", + return_path="id", + ) + mock_set_colormap.assert_called_once_with(expected_colormap_set) + assert isinstance(result, ColorBlending) + assert result.color_blending_id == 123 + + +def test_create_color_blending_raises_when_no_frames_are_open(session, mocker): + get_value = mocker.patch.object(session, "get_value", return_value=0) + call_action = mocker.patch.object(session, "call_action") + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + with pytest.raises(CartaActionFailed, match="No frames are open"): + session.create_color_blending() + + get_value.assert_called_once_with("frames.length") + call_action.assert_not_called() + mock_set_colormap.assert_not_called() # OPENING IMAGES From d6a479e9320f3be0220c45b3a84151e2f3c27e2e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 16:54:35 +0800 Subject: [PATCH 55/95] Reorganize Session methods with section comments for color blending and image-view items --- carta/session.py | 104 ++++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/carta/session.py b/carta/session.py index a938f23..a3cbf4e 100644 --- a/carta/session.py +++ b/carta/session.py @@ -511,6 +511,35 @@ def open_hypercube(self, image_paths, append=False): file_id = self.call_action(command, stokes_images, output_directory, output_hdu) return Image(self, file_id) + @validate(IterableOf(String())) + def open_as_color_blending(self, files): + """Open files and combine them into a new color blending image. + + This helper always opens the files with ``append=False``, which + closes any currently open images before opening ``files``. It + then makes the first opened image the spatial reference, enables + spatial matching for the remaining opened images, and calls + :obj:`create_color_blending`. + + Parameters + ---------- + files : {0} + The files to be blended. + + Returns + ------- + :obj:`carta.colorblending.ColorBlending` + The new color blending object. + """ + images = self.open_images(files, append=False) + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + cb = self.create_color_blending() + return cb + + # IMAGE-VIEW ITEMS + def image_list(self): """Return the list of currently open image-view items. @@ -654,32 +683,36 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= f"No color blending with color_blending_id={color_blending_id} is open." ) - @validate(IterableOf(String())) - def open_as_color_blending(self, files): - """Open files and combine them into a new color blending image. - - This helper always opens the files with ``append=False``, which - closes any currently open images before opening ``files``. It - then makes the first opened image the spatial reference, enables - spatial matching for the remaining opened images, and calls - :obj:`create_color_blending`. + def active_image(self): + """Return the currently active image-view item. - Parameters - ---------- - files : {0} - The files to be blended. + This is the frame-backed image or color blending image that is + currently active in the viewer. Returns ------- - :obj:`carta.colorblending.ColorBlending` - The new color blending object. + :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` + The currently active image-view item. + + Raises + ------ + NotImplementedError + If the active image is of a type that is not yet wrapped on + the Python side. """ - images = self.open_images(files, append=False) - images[0].make_spatial_reference() - for image in images[1:]: - image.set_spatial_matching(True) - cb = self.create_color_blending() - return cb + active_type = self.get_value("activeImage.type") + active_id = self.get_value("activeImage.store.id") + if active_type == ImageType.FRAME: + return Image(self, active_id) + if active_type == ImageType.COLOR_BLENDING: + return ColorBlending(self, active_id) + raise NotImplementedError( + f"active_image encountered an unsupported image-view type " + f"{active_type!r}; only Image (FRAME) and ColorBlending " + "(COLOR_BLENDING) entries are currently wrapped." + ) + + # COLOR BLENDING def create_color_blending(self): """Create a new color blending from the current spatial reference @@ -712,35 +745,6 @@ def create_color_blending(self): cb.set_colormap_set(ColormapSet.RAINBOW) return cb - def active_image(self): - """Return the currently active image-view item. - - This is the frame-backed image or color blending image that is - currently active in the viewer. - - Returns - ------- - :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` - The currently active image-view item. - - Raises - ------ - NotImplementedError - If the active image is of a type that is not yet wrapped on - the Python side. - """ - active_type = self.get_value("activeImage.type") - active_id = self.get_value("activeImage.store.id") - if active_type == ImageType.FRAME: - return Image(self, active_id) - if active_type == ImageType.COLOR_BLENDING: - return ColorBlending(self, active_id) - raise NotImplementedError( - f"active_image encountered an unsupported image-view type " - f"{active_type!r}; only Image (FRAME) and ColorBlending " - "(COLOR_BLENDING) entries are currently wrapped." - ) - def clear_spatial_reference(self): """Clear the spatial reference.""" self.call_action("clearSpatialReference") From 9d866163f133571a73b5ccf6d7aebdaa59f1ce6a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 17:25:11 +0800 Subject: [PATCH 56/95] Fix default colormap set selection to use layer count instead of total frame count in create_color_blending --- carta/session.py | 9 +++++---- tests/test_session.py | 31 +++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/carta/session.py b/carta/session.py index a3cbf4e..754b9bc 100644 --- a/carta/session.py +++ b/carta/session.py @@ -729,9 +729,9 @@ def create_color_blending(self): If no frames are open or the frontend could not create the color blending image. """ - frame_length = self.get_value("frames.length") - if frame_length <= 0: - raise CartaActionFailed("No frames are open") + frame_count = self.get_value("frames.length") + if frame_count <= 0: + raise CartaActionFailed("No frames are open.") color_blending_id = self.call_action( "imageViewConfigStore.createColorBlending", @@ -739,7 +739,8 @@ def create_color_blending(self): ) cb = ColorBlending(self, color_blending_id) - if frame_length <= 3: + layer_count = cb.get_value("frames.length") + if layer_count <= 3: cb.set_colormap_set(ColormapSet.RGB) else: cb.set_colormap_set(ColormapSet.RAINBOW) diff --git a/tests/test_session.py b/tests/test_session.py index 492e30d..fbe7812 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -280,16 +280,21 @@ def test_open_as_color_blending_opens_files_sets_matching_and_creates_blending( assert result is fake_cb -@pytest.mark.parametrize("frame_count,expected_colormap_set", [ - # <= 3 open frames -> RGB - (1, ColormapSet.RGB), - (2, ColormapSet.RGB), - (3, ColormapSet.RGB), - # > 3 open frames -> RAINBOW - (4, ColormapSet.RAINBOW), +@pytest.mark.parametrize("open_frame_count,layer_count,expected_colormap_set", [ + (1, 1, ColormapSet.RGB), + (3, 3, ColormapSet.RGB), + (5, 3, ColormapSet.RGB), + (5, 4, ColormapSet.RAINBOW), ]) -def test_create_color_blending_calls_frontend_create_action(session, mocker, frame_count, expected_colormap_set): - get_value = mocker.patch.object(session, "get_value", return_value=frame_count) +def test_create_color_blending_calls_frontend_create_action(session, mocker, open_frame_count, layer_count, expected_colormap_set): + get_value = mocker.patch.object( + session, + "get_value", + side_effect=[ + open_frame_count, + layer_count, + ], + ) call_action = mocker.patch.object( session, "call_action", @@ -298,7 +303,13 @@ def test_create_color_blending_calls_frontend_create_action(session, mocker, fra mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") result = session.create_color_blending() - get_value.assert_called_once_with("frames.length") + assert get_value.call_args_list == [ + mocker.call("frames.length"), + mocker.call( + "imageViewConfigStore.colorBlendingImageMap[123].frames.length", + return_path=None, + ), + ] call_action.assert_called_once_with( "imageViewConfigStore.createColorBlending", return_path="id", From bebe9e4893e4432a1cc19a6fe2c17f15da04b48d Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 18:12:02 +0800 Subject: [PATCH 57/95] Add carta_version property to Session and include it in __repr__ when available --- carta/session.py | 34 ++++++++++++++++++++++++++++++++-- tests/test_session.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/carta/session.py b/carta/session.py index 754b9bc..66fce41 100644 --- a/carta/session.py +++ b/carta/session.py @@ -14,7 +14,7 @@ from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, cached, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -51,6 +51,8 @@ class Session: ---------- session_id : integer The ID of the CARTA frontend session associated with this object. + carta_version : string + The CARTA version string reported by the frontend. wcs : :obj:`carta.wcs_overlay.SessionWCSOverlay` Sub-object with functions related to the WCS overlay. raster : :obj:`carta.raster.SessionRaster` @@ -233,7 +235,35 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa def __repr__(self): """A human-readable representation of this session object.""" - return f"Session(session_id={self.session_id}, uri={self._protocol.frontend_url if self._protocol else None})" + uri = self._protocol.frontend_url if self._protocol else None + cache = getattr(self, "_cache", {}) + + if "carta_version" in cache: + version = cache["carta_version"] + else: + try: + version = self.carta_version + except (AttributeError, CartaScriptingException): + return f"Session(session_id={self.session_id}, uri={uri!r})" + + return ( + f"Session(session_id={self.session_id}, uri={uri!r}, " + f"carta_version={version!r})" + ) + + # METADATA + + @property + @cached + def carta_version(self): + """The CARTA version. + + Returns + ------- + string + The version string reported by the frontend. + """ + return self.get_value("frontendVersion") def call_action(self, path, *args, **kwargs): """Call an action on the frontend through the backend's scripting interface. diff --git a/tests/test_session.py b/tests/test_session.py index fbe7812..5a984c7 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -33,6 +33,36 @@ def method(session, mock_method): def test_subobjects(session, name, classname): assert getattr(session, name).__class__.__name__ == classname + +def test_carta_version_property(session, get_value): + get_value.return_value = "6.0.0" + + assert session.carta_version == "6.0.0" + assert session.carta_version == "6.0.0" + + get_value.assert_called_once_with("frontendVersion") + + +def test_session_repr_includes_carta_version(session, get_value, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + get_value.return_value = "6.0.0" + + assert ( + repr(session) + == "Session(session_id=0, uri='http://localhost:3000', carta_version='6.0.0')" + ) + + +def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + mocker.patch.object( + session, + "get_value", + side_effect=CartaActionFailed("frontendVersion unavailable"), + ) + + assert repr(session) == "Session(session_id=0, uri='http://localhost:3000')" + # PATHS From 76a6c37136beca68b4c33fbc6760b867dbb4cc3b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 19:29:34 +0800 Subject: [PATCH 58/95] Update documentation references to use new wcs module paths and improve file_id description clarity --- carta/colorblending.py | 4 ++-- carta/image.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 8af7ec7..2d00a2b 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -406,8 +406,8 @@ 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.wcs_overlay.Global.set_coordinate_system` and - :obj:`carta.wcs_overlay.Numbers.set_format`. + :obj:`carta.session.wcs.global_.set_coordinate_system` and + :obj:`carta.session.wcs.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 4bbca67..f966b2d 100644 --- a/carta/image.py +++ b/carta/image.py @@ -27,14 +27,14 @@ class Image(ImageBase, BasePathMixin): session : :obj:`carta.session.Session` The session object associated with this image. file_id : integer - The frontend file id identifying this image. This is a unique number which is not reused, not the index of the image within the list of currently open images. + The frontend file ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. file_id : integer - The frontend file id identifying this image. + The frontend file ID identifying this image within the session. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` @@ -374,7 +374,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.wcs_overlay.Global.set_coordinate_system` and :obj:`carta.wcs_overlay.Numbers.set_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.session.wcs.global_.set_coordinate_system` and :obj:`carta.session.wcs.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 49e4e78cfcaaf1a6f1bea873d4128a96935e18f0 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 19:34:09 +0800 Subject: [PATCH 59/95] Add zoom_to_size method to ColorBlending class with validation and delegate to base frame implementation --- carta/colorblending.py | 23 ++++++++++++++++++++++- tests/test_colorblending.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 2d00a2b..95241ec 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -1,6 +1,6 @@ """This module contains functionality for interacting with color blending images and their layers.""" -from .constants import Colormap, ColormapSet, ImageType +from .constants import Colormap, ColormapSet, ImageType, SpatialAxis from .image import Image from .image_base import ImageBase from .util import BasePathMixin, CartaScriptingException, Macro @@ -11,6 +11,7 @@ InstanceOf, IterableOf, Number, + Size, validate, ) @@ -430,6 +431,26 @@ def set_center(self, x, y): """ self._base_frame.set_center(x, y) + @validate(Size(), Constant(SpatialAxis)) + def zoom_to_size(self, size, axis): + """Zoom to the given size along the specified axis. + + Numbers are interpreted as pixel sizes. Numeric strings with no units are interpreted as arcseconds. + + Parameters + ---------- + size : {0} + The size to zoom to. + axis : {1} + The spatial axis to use. + + Raises + ------ + ValueError + If an angular size is provided and the image has no valid WCS information. + """ + self._base_frame.zoom_to_size(size, axis) + @validate(Number(), Boolean()) def set_zoom_level(self, zoom, absolute=True): """Set the zoom level. diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 24972f5..d1e891a 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -4,6 +4,7 @@ from carta.constants import Colormap as CM from carta.constants import ColormapSet as CMS from carta.constants import ImageType +from carta.constants import SpatialAxis as SA from carta.image import Image from carta.util import CartaActionFailed, CartaValidationFailed, Macro @@ -484,6 +485,34 @@ def test_colorblending_set_center(colorblending, mocker): base_frame.set_center.assert_called_once_with(1, 2) +@pytest.mark.parametrize("size,axis", [(123, SA.X), ("123arcsec", SA.Y)]) +def test_colorblending_zoom_to_size(colorblending, mocker, size, axis): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + colorblending.zoom_to_size(size, axis) + base_frame.zoom_to_size.assert_called_once_with(size, axis) + + +@pytest.mark.parametrize("size,axis", [("123px", SA.X), (123, "z")]) +def test_colorblending_zoom_to_size_invalid(colorblending, mocker, size, axis): + base_frame = mocker.create_autospec(Image, instance=True) + mocker.patch( + "carta.colorblending.ColorBlending._base_frame", + new_callable=mocker.PropertyMock, + return_value=base_frame, + ) + + with pytest.raises(CartaValidationFailed): + colorblending.zoom_to_size(size, axis) + + base_frame.zoom_to_size.assert_not_called() + + @pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): base_frame = mocker.create_autospec(Image, instance=True) From 21b22f026673ae0b720eaac2abc943ca62cf6bc4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 19:50:27 +0800 Subject: [PATCH 60/95] Refactor ImageBase to use ABC pattern, move image_view_order implementation to base class, and consolidate session initialization via super().__init__ --- carta/colorblending.py | 20 +----------- carta/image.py | 18 +---------- carta/image_base.py | 30 ++++++++++++++++-- tests/test_image.py | 69 ++++++++++++++++++++++++++++++++++++------ 4 files changed, 88 insertions(+), 49 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index 95241ec..ebe2b9e 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -181,7 +181,7 @@ class ColorBlending(ImageBase, BasePathMixin): _image_type = ImageType.COLOR_BLENDING def __init__(self, session, color_blending_id): - self.session = session + super().__init__(session) self.color_blending_id = color_blending_id path = "imageViewConfigStore.colorBlendingImageMap" @@ -192,24 +192,6 @@ def __init__(self, session, color_blending_id): def _stable_id(self): return self.color_blending_id - @property - def image_view_order(self): - """The current index of this color blending in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching color blending entry exists in the image list. - """ - return self.session._find_image_view_order( - ImageType.COLOR_BLENDING, self.color_blending_id - ) - def __repr__(self): """A human-readable representation of this color blending object.""" cls = type(self).__name__ diff --git a/carta/image.py b/carta/image.py index f966b2d..d8deb17 100644 --- a/carta/image.py +++ b/carta/image.py @@ -50,7 +50,7 @@ class Image(ImageBase, BasePathMixin): _image_type = ImageType.FRAME def __init__(self, session, file_id): - self.session = session + super().__init__(session) self.file_id = file_id self._base_path = f"frameMap[{file_id}]" @@ -108,22 +108,6 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ file_id = session.call_action(command, *params, return_path="frameInfo.fileId") return cls(session, file_id) - @property - def image_view_order(self): - """The current index of this image in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching frame entry exists in the image list. - """ - return self.session._find_image_view_order(ImageType.FRAME, self.file_id) - def __repr__(self): """A human-readable representation of this image object.""" cls = type(self).__name__ diff --git a/carta/image_base.py b/carta/image_base.py index bd0e8bf..e46446c 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -4,10 +4,12 @@ """ +from abc import ABC, abstractmethod + from .constants import ImageType -class ImageBase: +class ImageBase(ABC): """Base class for image-view items (frame-backed images and color blendings). This class is not intended to be instantiated directly. @@ -24,17 +26,39 @@ def __init__(self, session): self.session = session @property + @abstractmethod def _stable_id(self): """The stable identifier of this image-view item.""" raise NotImplementedError + def _require_image_type(self): + if self._image_type is None: + raise NotImplementedError( + "Subclasses must define _image_type." + ) + @property def image_view_order(self): - """The index of this item in image list.""" - raise NotImplementedError + """The current index of this item in image list. + + Returns + ------- + integer + The image view order. + + Raises + ------ + RuntimeError + If no matching entry exists in the image list. + """ + self._require_image_type() + return self.session._find_image_view_order( + self._image_type, self._stable_id + ) def make_active(self): """Make this the active image-view item.""" + self._require_image_type() self.session.call_action( "setActiveImageById", self._image_type, self._stable_id ) diff --git a/tests/test_image.py b/tests/test_image.py index 589e71b..2723c50 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -115,16 +115,9 @@ def test_make_active(image, session_call_action): ) -def test_image_base_image_view_order_not_implemented(session): - base = ImageBase(session) - with pytest.raises(NotImplementedError): - base.image_view_order - - -def test_image_base_stable_id_not_implemented(session): - base = ImageBase(session) - with pytest.raises(NotImplementedError): - base._stable_id +def test_image_base_is_abstract(session): + with pytest.raises(TypeError, match="abstract method _stable_id"): + ImageBase(session) def test_image_base_make_active_uses_subclass_ids(session, session_call_action): @@ -147,6 +140,62 @@ def _stable_id(self): ) +def test_image_base_image_view_order_uses_subclass_ids(session, mocker): + class Dummy(ImageBase): + _image_type = ImageType.COLOR_BLENDING + + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + find = mocker.patch.object(session, "_find_image_view_order", return_value=5) + assert Dummy(session, 42).image_view_order == 5 + find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) + + +def test_image_base_image_type_required_for_make_active( + session, session_call_action +): + class Dummy(ImageBase): + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + with pytest.raises( + NotImplementedError, match="Subclasses must define _image_type" + ): + Dummy(session, 42).make_active() + + session_call_action.assert_not_called() + + +def test_image_base_image_type_required_for_image_view_order(session, mocker): + class Dummy(ImageBase): + def __init__(self, session, id_): + super().__init__(session) + self._id = id_ + + @property + def _stable_id(self): + return self._id + + find = mocker.patch.object(session, "_find_image_view_order") + with pytest.raises( + NotImplementedError, match="Subclasses must define _image_type" + ): + Dummy(session, 42).image_view_order + + find.assert_not_called() + + def test_image_view_order_uses_summary_once(session, mocker, image): find = mocker.patch.object( session, "_find_image_view_order", return_value=3 From 1c5b8ab69dbb00b0f7919e81092c21df4119e7f7 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:06:18 +0800 Subject: [PATCH 61/95] Update quickstart documentation to use unpacked layer variables instead of indexed access for improved readability --- docs/source/quickstart.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index ba6ff36..42820f5 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -307,12 +307,12 @@ Manipulate properties of the color blending object and the underlying layers: .. code-block:: python # Get layer objects - layers = cb.layer_list() + red, green, blue = 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) + red.set_colormap(Colormap.REDS) + green.set_colormap(Colormap.GREENS) + blue.set_colormap(Colormap.BLUES) # Or apply an existing colormap set cb.set_colormap_set(ColormapSet.RGB) @@ -321,9 +321,9 @@ Manipulate properties of the color blending object and the underlying 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) + red.set_alpha(0.7) + green.set_alpha(0.8) + blue.set_alpha(0.9) # Or set alpha for all layers at once cb.set_alpha([0.7, 0.8, 0.9]) From 8940ca36d04bdc26d6a5ba6416110c94a55fe159 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:09:52 +0800 Subject: [PATCH 62/95] Update test regex pattern to match abstract method error messages more flexibly --- tests/test_image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_image.py b/tests/test_image.py index 2723c50..24f87dd 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -116,7 +116,7 @@ def test_make_active(image, session_call_action): def test_image_base_is_abstract(session): - with pytest.raises(TypeError, match="abstract method _stable_id"): + with pytest.raises(TypeError, match=r"abstract method.*_stable_id"): ImageBase(session) From f0b86ae496c8518aaaad96d74ccf9b1602bb7900 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:27:57 +0800 Subject: [PATCH 63/95] Fix a test warning --- carta/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carta/util.py b/carta/util.py index 6df380c..5b4ebb4 100644 --- a/carta/util.py +++ b/carta/util.py @@ -130,7 +130,7 @@ def newfunc(self, *args): return self._cache[func.__name__] if newfunc.__doc__ is not None: - newfunc.__doc__ = re.sub(r"($|\n)", r" This value is transparently cached on the parent object.\1", newfunc.__doc__, 1) + newfunc.__doc__ = re.sub(r"($|\n)", r" This value is transparently cached on the parent object.\1", newfunc.__doc__, count=1) return newfunc From 1e4d2fe56675403d3f88568182ab63bea6c33aef Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:28:13 +0800 Subject: [PATCH 64/95] Add test coverage --- carta/image_base.py | 2 +- tests/test_colorblending.py | 24 ++++++++++++++++++++++++ tests/test_session.py | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/carta/image_base.py b/carta/image_base.py index e46446c..05ed39b 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -29,7 +29,7 @@ def __init__(self, session): @abstractmethod def _stable_id(self): """The stable identifier of this image-view item.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover def _require_image_type(self): if self._image_type is None: diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index d1e891a..663e86c 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -382,6 +382,30 @@ def test_colorblending_set_layer_sequence(session, colorblending, mocker): assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] +def test_colorblending_set_layer_sequence_noop_when_order_is_unchanged( + colorblending, mocker +): + mocker.patch.object( + ColorBlending, + "layer_list", + return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], + ) + mocker.patch( + "carta.colorblending.ColorBlending.alpha", + new_callable=mocker.PropertyMock, + side_effect=AssertionError("alpha should not be read"), + ) + 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, 1, 2]) + + del_layer.assert_not_called() + add_layer.assert_not_called() + set_alpha.assert_not_called() + + def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( session, colorblending, mocker ): diff --git a/tests/test_session.py b/tests/test_session.py index 5a984c7..081121a 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import CartaActionFailed, Macro +from carta.util import CartaActionFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -53,6 +53,20 @@ def test_session_repr_includes_carta_version(session, get_value, mocker): ) +def test_session_repr_uses_cached_carta_version_without_lookup( + session, mocker +): + session._protocol = mocker.Mock(frontend_url="http://localhost:3000") + session._cache = {"carta_version": "6.0.0"} + get_value = mocker.patch.object(session, "get_value") + + assert ( + repr(session) + == "Session(session_id=0, uri='http://localhost:3000', carta_version='6.0.0')" + ) + get_value.assert_not_called() + + def test_session_repr_omits_carta_version_when_lookup_fails(session, mocker): session._protocol = mocker.Mock(frontend_url="http://localhost:3000") mocker.patch.object( @@ -207,6 +221,17 @@ def test_image_by_id_by_image_view_order_out_of_range(session, summary): session.image_by_id(image_view_order=99) +def test_image_by_id_by_image_view_order_raises_on_unsupported_type( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.PV_PREVIEW, "id": -2}, + ] + + with pytest.raises(NotImplementedError): + session.image_by_id(image_view_order=0) + + def test_image_by_id_by_file_id(session, summary): img = session.image_by_id(file_id=20) assert isinstance(img, Image) @@ -361,6 +386,13 @@ def test_create_color_blending_raises_when_no_frames_are_open(session, mocker): call_action.assert_not_called() mock_set_colormap.assert_not_called() + +def test_set_cursor(session, call_action): + session.set_cursor(1, 2) + call_action.assert_called_once_with( + "activeFrame.setCursorPosition", Pt(1, 2) + ) + # OPENING IMAGES From 8620e85f2cd090f882fbd2b78f1724605dd06557 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:41:43 +0800 Subject: [PATCH 65/95] Improve error messages to include actual values and update tests to verify detailed error content --- carta/colorblending.py | 30 ++++++++++++++++++++++-------- carta/session.py | 5 ++++- tests/test_colorblending.py | 33 ++++++++++++++------------------- tests/test_session.py | 5 ++++- 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/carta/colorblending.py b/carta/colorblending.py index ebe2b9e..aab8f84 100644 --- a/carta/colorblending.py +++ b/carta/colorblending.py @@ -329,30 +329,44 @@ def set_layer_sequence(self, layer_indices): must be the base layer (index = 0). Existing alpha values are preserved. """ + layer_indices = list(layer_indices) current_layers = self.layer_list() max_current_layer_index = len(current_layers) - 1 - if any( - layer_index > max_current_layer_index + invalid_layer_indices = [ + layer_index for layer_index in layer_indices - ): + if layer_index > max_current_layer_index + ] + if invalid_layer_indices: raise ValueError( - "layer_indices contains a layer index which does not exist." + f"layer_indices {layer_indices!r} contains non-existent layer " + f"indices {invalid_layer_indices!r}; available layer indices " + f"are 0..{max_current_layer_index}." ) if layer_indices[0] != 0: raise ValueError( - "layer_indices must start with the base layer index 0." + f"layer_indices {layer_indices!r} 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." + f"layer_indices {layer_indices!r} must contain the base layer " + "index 0 only once, as the first index." ) if len(layer_indices) != len(set(layer_indices)): + duplicate_layer_indices = sorted( + { + layer_index + for layer_index in layer_indices + if layer_indices.count(layer_index) > 1 + } + ) raise ValueError( - "layer_indices must not contain duplicate layer indices." + f"layer_indices {layer_indices!r} must not contain duplicate " + f"layer indices; duplicates were {duplicate_layer_indices!r}." ) current_layer_indices = list(range(len(current_layers))) diff --git a/carta/session.py b/carta/session.py index 66fce41..c3579b7 100644 --- a/carta/session.py +++ b/carta/session.py @@ -666,10 +666,13 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= } given = [key for key, val in provided.items() if val is not None] if len(given) != 1: + given_values = { + key: val for key, val in provided.items() if val is not None + } raise ValueError( "image_by_id requires exactly one of the keyword arguments " "`image_view_order`, `file_id`, or `color_blending_id`; " - f"got {len(given)}." + f"got {len(given)} with values {given_values!r}." ) summary = self.get_value("imageViewConfigStore.imageListSummary") diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index 663e86c..c14cdb1 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -439,11 +439,11 @@ def test_colorblending_set_layer_sequence_rejects_missing_layer_index( 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.", - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([0, 4, 1]) + assert "layer_indices [0, 4, 1]" in str(e.value) + assert "[4]" in str(e.value) + assert "0..3" in str(e.value) def test_colorblending_set_layer_sequence_requires_base_layer_first( @@ -455,11 +455,10 @@ def test_colorblending_set_layer_sequence_requires_base_layer_first( 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.", - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([2, 1]) + assert "layer_indices [2, 1]" in str(e.value) + assert "must start with the base layer index 0" in str(e.value) def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( @@ -471,14 +470,10 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( 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." - ), - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([0, 2, 0]) + assert "layer_indices [0, 2, 0]" in str(e.value) + assert "must contain the base layer index 0 only once" in str(e.value) def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( @@ -490,11 +485,11 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) - with pytest.raises( - ValueError, - match="layer_indices must not contain duplicate layer indices.", - ): + with pytest.raises(ValueError) as e: colorblending.set_layer_sequence([0, 1, 1]) + assert "layer_indices [0, 1, 1]" in str(e.value) + assert "must not contain duplicate layer indices" in str(e.value) + assert "[1]" in str(e.value) def test_colorblending_set_center(colorblending, mocker): diff --git a/tests/test_session.py b/tests/test_session.py index 081121a..54c2f0b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -192,9 +192,12 @@ def test_image_by_id_requires_exactly_one_keyword(session, get_value): session.image_by_id() for name in ("image_view_order", "file_id", "color_blending_id"): assert name in str(e.value) + assert "got 0 with values {}" in str(e.value) # Multiple keywords -> ValueError. - with pytest.raises(ValueError): + with pytest.raises(ValueError) as e: session.image_by_id(file_id=1, color_blending_id=2) + assert "'file_id': 1" in str(e.value) + assert "'color_blending_id': 2" in str(e.value) def test_image_by_id_rejects_positional(session): From c982c8916cff49bcc1cbdbe12417cb73cdba8db8 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 20:43:01 +0800 Subject: [PATCH 66/95] Add session.clear_spatial_reference() call before setting new spatial reference in color blending example --- docs/source/quickstart.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 42820f5..00315b8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -291,6 +291,7 @@ The session object provides two convenience methods which create a color blendin # and its currently spatially matched frames. # Set the desired base image as the current spatial reference and # enable spatial matching for the other layers first. + session.clear_spatial_reference() img0.make_spatial_reference() img1.set_spatial_matching(True) img2.set_spatial_matching(True) From ab1753d2171c04f85a07f0682f24f7358f867e85 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 21:17:19 +0800 Subject: [PATCH 67/95] Add validation to reject empty file list in open_as_color_blending method and verify error message includes minimum size requirement --- carta/session.py | 2 +- tests/test_session.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/carta/session.py b/carta/session.py index c3579b7..7f14db9 100644 --- a/carta/session.py +++ b/carta/session.py @@ -541,7 +541,7 @@ def open_hypercube(self, image_paths, append=False): file_id = self.call_action(command, stokes_images, output_directory, output_hdu) return Image(self, file_id) - @validate(IterableOf(String())) + @validate(IterableOf(String(), min_size=1)) def open_as_color_blending(self, files): """Open files and combine them into a new color blending image. diff --git a/tests/test_session.py b/tests/test_session.py index 54c2f0b..12841db 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,7 @@ from carta.image import Image from carta.colorblending import ColorBlending -from carta.util import CartaActionFailed, Macro, Point as Pt +from carta.util import CartaActionFailed, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -338,6 +338,20 @@ def test_open_as_color_blending_opens_files_sets_matching_and_creates_blending( assert result is fake_cb +def test_open_as_color_blending_rejects_empty_file_list(session, mocker): + open_images = mocker.patch.object(session, "open_images") + create_color_blending = mocker.patch.object( + session, "create_color_blending" + ) + + with pytest.raises(CartaValidationFailed) as e: + session.open_as_color_blending([]) + + assert "at least 1" in str(e.value) + open_images.assert_not_called() + create_color_blending.assert_not_called() + + @pytest.mark.parametrize("open_frame_count,layer_count,expected_colormap_set", [ (1, 1, ColormapSet.RGB), (3, 3, ColormapSet.RGB), From 8f6ab9ea03b4cd5bd4c9f7e03b6c1410cc758403 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 22 Apr 2026 21:56:02 +0800 Subject: [PATCH 68/95] Optimize active_image method to retrieve activeImage object once instead of making separate calls for type and store.id properties --- carta/session.py | 5 +++-- tests/test_session.py | 18 +++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/carta/session.py b/carta/session.py index 7f14db9..25a6cc5 100644 --- a/carta/session.py +++ b/carta/session.py @@ -733,8 +733,9 @@ def active_image(self): If the active image is of a type that is not yet wrapped on the Python side. """ - active_type = self.get_value("activeImage.type") - active_id = self.get_value("activeImage.store.id") + active = self.get_value("activeImage") + active_type = active["type"] + active_id = active["store"]["id"] if active_type == ImageType.FRAME: return Image(self, active_id) if active_type == ImageType.COLOR_BLENDING: diff --git a/tests/test_session.py b/tests/test_session.py index 12841db..42690f1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -274,31 +274,35 @@ def test_image_by_id_single_round_trip(session, summary): def test_active_image_returns_image_when_frame_active(session, get_value): - get_value.side_effect = [ImageType.FRAME, 12] + get_value.side_effect = [ + {"type": ImageType.FRAME, "store": {"id": 12}}, + ] active = session.active_image() assert isinstance(active, Image) assert active.file_id == 12 assert [call.args for call in get_value.call_args_list] == [ - ("activeImage.type",), - ("activeImage.store.id",), + ("activeImage",), ] def test_active_image_returns_color_blending_when_color_blending_active( session, get_value ): - get_value.side_effect = [ImageType.COLOR_BLENDING, 3] + get_value.side_effect = [ + {"type": ImageType.COLOR_BLENDING, "store": {"id": 3}}, + ] active = session.active_image() assert isinstance(active, ColorBlending) assert active.color_blending_id == 3 assert [call.args for call in get_value.call_args_list] == [ - ("activeImage.type",), - ("activeImage.store.id",), + ("activeImage",), ] def test_active_image_raises_on_unsupported_type(session, get_value): - get_value.side_effect = [ImageType.PV_PREVIEW, -2] + get_value.side_effect = [ + {"type": ImageType.PV_PREVIEW, "store": {"id": -2}}, + ] with pytest.raises(NotImplementedError): session.active_image() From 65eacedd8d1663c9af6f1622ba1225c021a9a567 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 23 Apr 2026 13:56:18 +0800 Subject: [PATCH 69/95] Refactor _find_image_view_order to use imageViewConfigStore.getImageListIndex action instead of manually iterating through imageListSummary --- carta/session.py | 20 ++++++++++++-------- tests/test_colorblending.py | 37 ++++++++++++------------------------- tests/test_image.py | 21 ++------------------- tests/test_session.py | 31 ++++++++++++++++++++----------- 4 files changed, 46 insertions(+), 63 deletions(-) diff --git a/carta/session.py b/carta/session.py index 25a6cc5..c037bd1 100644 --- a/carta/session.py +++ b/carta/session.py @@ -609,21 +609,25 @@ def _find_image_view_order(self, image_type, stable_id): Returns ------- integer - The image-view order of the first matching entry. + The image-view order of the matching entry. Raises ------ RuntimeError If no matching entry exists in the image list. """ - summary = self.get_value("imageViewConfigStore.imageListSummary") - for idx, entry in enumerate(summary): - if entry["type"] == image_type and entry["id"] == stable_id: - return idx - raise RuntimeError( - f"Could not find an image of type {image_type!r} with id " - f"{stable_id} in the image list." + image_view_order = self.call_action( + "imageViewConfigStore.getImageListIndex", + image_type, + stable_id, + response_expected=True, ) + if image_view_order == -1: + raise RuntimeError( + f"Could not find an image of type {image_type!r} with id " + f"{stable_id} in the image list." + ) + return image_view_order def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): """Return the image-view item identified by exactly one of the supported identifiers. diff --git a/tests/test_colorblending.py b/tests/test_colorblending.py index c14cdb1..f34dd4c 100644 --- a/tests/test_colorblending.py +++ b/tests/test_colorblending.py @@ -198,7 +198,7 @@ def test_colorblending_init(session): def test_colorblending_repr_healthy(session, colorblending, cb_property, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch.object(session, "call_action", return_value=2) cb_property("file_name", "Color Blending 1") r = repr(colorblending) assert r == ( @@ -212,7 +212,7 @@ def test_colorblending_repr_closed_when_not_in_image_list( ): mocker.patch.object( session, - "_find_image_view_order", + "call_action", side_effect=RuntimeError("not in image list"), ) r = repr(colorblending) @@ -224,7 +224,7 @@ def test_colorblending_repr_closed_when_not_in_image_list( def test_colorblending_repr_closed_when_backing_entry_is_gone( session, colorblending, mocker ): - mocker.patch.object(session, "_find_image_view_order", return_value=2) + mocker.patch.object(session, "call_action", return_value=2) mocker.patch( "carta.colorblending.ColorBlending.file_name", new_callable=mocker.PropertyMock, @@ -242,35 +242,22 @@ def test_colorblending_file_name(colorblending, cb_get_value): def test_colorblending_image_view_order( - session, colorblending, session_get_value + session, colorblending, session_call_action ): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 99}, - {"type": ImageType.COLOR_BLENDING, "id": 0}, - ] + session_call_action.return_value = 2 assert colorblending.image_view_order == 2 - session_get_value.assert_called_once_with( - "imageViewConfigStore.imageListSummary" + session_call_action.assert_called_once_with( + "imageViewConfigStore.getImageListIndex", + ImageType.COLOR_BLENDING, + 0, + response_expected=True, ) -def test_colorblending_image_view_order_ignores_non_color_blending( - session, colorblending, session_get_value -): - session_get_value.return_value = [ - {"type": ImageType.FRAME, "id": 0}, - {"type": ImageType.COLOR_BLENDING, "id": 0}, - ] - assert colorblending.image_view_order == 1 - - def test_colorblending_image_view_order_raises_when_missing( - session, colorblending, session_get_value + session, colorblending, session_call_action ): - session_get_value.return_value = [ - {"type": ImageType.COLOR_BLENDING, "id": 99}, - ] + session_call_action.return_value = -1 with pytest.raises(RuntimeError): colorblending.image_view_order diff --git a/tests/test_image.py b/tests/test_image.py index 24f87dd..7f2a969 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -196,7 +196,7 @@ def _stable_id(self): find.assert_not_called() -def test_image_view_order_uses_summary_once(session, mocker, image): +def test_image_view_order_uses_find_image_view_order(session, mocker, image): find = mocker.patch.object( session, "_find_image_view_order", return_value=3 ) @@ -205,26 +205,9 @@ def test_image_view_order_uses_summary_once(session, mocker, image): find.assert_called_once_with(ImageType.FRAME, 0) -def test_image_view_order_ignores_non_frame_entries(session, mocker): - get_value = mocker.patch.object( - session, - "get_value", - return_value=[ - {"type": ImageType.COLOR_BLENDING, "id": 0}, - {"type": ImageType.FRAME, "id": 7}, - {"type": ImageType.FRAME, "id": 3}, - ], - ) - img = Image(session, 3) - assert img.image_view_order == 2 - get_value.assert_called_once_with("imageViewConfigStore.imageListSummary") - - def test_image_view_order_raises_when_missing(session, mocker): mocker.patch.object( - session, - "get_value", - return_value=[{"type": ImageType.FRAME, "id": 99}], + session, "_find_image_view_order", side_effect=RuntimeError ) img = Image(session, 3) with pytest.raises(RuntimeError): diff --git a/tests/test_session.py b/tests/test_session.py index 42690f1..d9a70a4 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,3 +1,5 @@ +from unittest.mock import call + import pytest from carta.image import Image @@ -153,22 +155,29 @@ def test_image_list_empty(session, get_value): ) -def test_find_image_view_order_single_round_trip(session, get_value): - get_value.return_value = [ - {"type": ImageType.FRAME, "id": 7}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - {"type": ImageType.FRAME, "id": 3}, - ] +def test_find_image_view_order_single_round_trip(session, call_action): + call_action.side_effect = [2, 1] assert session._find_image_view_order(ImageType.FRAME, 3) == 2 assert session._find_image_view_order(ImageType.COLOR_BLENDING, 7) == 1 - assert get_value.call_count == 2 - for call in get_value.call_args_list: - assert call.args == ("imageViewConfigStore.imageListSummary",) + assert call_action.call_args_list == [ + call( + "imageViewConfigStore.getImageListIndex", + ImageType.FRAME, + 3, + response_expected=True, + ), + call( + "imageViewConfigStore.getImageListIndex", + ImageType.COLOR_BLENDING, + 7, + response_expected=True, + ), + ] -def test_find_image_view_order_raises_when_missing(session, get_value): - get_value.return_value = [{"type": ImageType.FRAME, "id": 1}] +def test_find_image_view_order_raises_when_missing(session, call_action): + call_action.return_value = -1 with pytest.raises(RuntimeError): session._find_image_view_order(ImageType.FRAME, 99) From 298043ca1d9c02b087bc1274f7dcb45f42f5ab71 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 23 Apr 2026 14:03:21 +0800 Subject: [PATCH 70/95] Rename loop variable from 'call' to 'call_' to avoid shadowing built-in name --- tests/test_session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_session.py b/tests/test_session.py index d9a70a4..91e6a96 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -275,8 +275,8 @@ def test_image_by_id_single_round_trip(session, summary): session.image_by_id(file_id=10) session.image_by_id(color_blending_id=7) assert summary.call_count == 3 - for call in summary.call_args_list: - assert call.args == ("imageViewConfigStore.imageListSummary",) + for call_ in summary.call_args_list: + assert call_.args == ("imageViewConfigStore.imageListSummary",) # session.active_image From d421fe91023962b2121f85733cd45b277fd4a750 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Tue, 28 Apr 2026 13:22:33 +0800 Subject: [PATCH 71/95] Simplify __repr__ method by removing manual cache checking and relying on carta_version property's built-in caching --- carta/session.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/carta/session.py b/carta/session.py index c037bd1..e10a50f 100644 --- a/carta/session.py +++ b/carta/session.py @@ -236,15 +236,10 @@ def start_and_create(cls, browser, executable_path="carta", remote_host=None, pa def __repr__(self): """A human-readable representation of this session object.""" uri = self._protocol.frontend_url if self._protocol else None - cache = getattr(self, "_cache", {}) - - if "carta_version" in cache: - version = cache["carta_version"] - else: - try: - version = self.carta_version - except (AttributeError, CartaScriptingException): - return f"Session(session_id={self.session_id}, uri={uri!r})" + try: + version = self.carta_version + except (AttributeError, CartaScriptingException): + return f"Session(session_id={self.session_id}, uri={uri!r})" return ( f"Session(session_id={self.session_id}, uri={uri!r}, " From 8a5e6f2fd7dcc5be2a154faeed93e2da3ee61fcc Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Mon, 4 May 2026 14:02:57 +0800 Subject: [PATCH 72/95] Rename colorblending module to color_blending and update all references to use snake_case naming convention --- carta/{colorblending.py => color_blending.py} | 28 +- carta/image_base.py | 2 +- carta/session.py | 10 +- docs/source/carta.rst | 7 +- docs/source/quickstart.rst | 4 +- ...olorblending.py => test_color_blending.py} | 274 +++++++++--------- tests/test_session.py | 2 +- 7 files changed, 163 insertions(+), 164 deletions(-) rename carta/{colorblending.py => color_blending.py} (94%) rename tests/{test_colorblending.py => test_color_blending.py} (63%) diff --git a/carta/colorblending.py b/carta/color_blending.py similarity index 94% rename from carta/colorblending.py rename to carta/color_blending.py index aab8f84..b1d20a1 100644 --- a/carta/colorblending.py +++ b/carta/color_blending.py @@ -21,14 +21,14 @@ class Layer(BasePathMixin): Parameters ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` + color_blending : :obj:`carta.color_blending.ColorBlending` The color blending object. layer_id : integer The layer ID. Attributes ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` + color_blending : :obj:`carta.color_blending.ColorBlending` The color blending object. layer_id : integer The layer ID. @@ -36,32 +36,32 @@ class Layer(BasePathMixin): The session object associated with this layer. """ - def __init__(self, colorblending, layer_id): - self.colorblending = colorblending + def __init__(self, color_blending, layer_id): + self.color_blending = color_blending self.layer_id = layer_id - self.session = colorblending.session + self.session = color_blending.session - self._base_path = f"{self.colorblending._base_path}.frames[{layer_id}]" + self._base_path = f"{self.color_blending._base_path}.frames[{layer_id}]" self._frame = Macro("", self._base_path) @classmethod - def from_list(cls, colorblending, layer_ids): + def from_list(cls, color_blending, layer_ids): """ Create a list of Layer objects from a list of layer IDs. Parameters ---------- - colorblending : :obj:`carta.colorblending.ColorBlending` + color_blending : :obj:`carta.color_blending.ColorBlending` The color blending object. layer_ids : list of integer The layer IDs. Returns ------- - list of :obj:`carta.colorblending.Layer` + list of :obj:`carta.color_blending.Layer` A list of new Layer objects. """ - return [cls(colorblending, layer_id) for layer_id in layer_ids] + return [cls(color_blending, layer_id) for layer_id in layer_ids] @property def image_view_order(self): @@ -70,7 +70,7 @@ def image_view_order(self): This is the position of the underlying frame in the session's image list. A layer does not occupy its own position in the image list; its parent color blending does (see - :obj:`carta.colorblending.ColorBlending.image_view_order`). + :obj:`carta.color_blending.ColorBlending.image_view_order`). Returns ------- @@ -89,7 +89,7 @@ def image_view_order(self): def __repr__(self): """A human-readable representation of this layer.""" cls = type(self).__name__ - cb_id = self.colorblending.color_blending_id + cb_id = self.color_blending.color_blending_id try: order = self.image_view_order @@ -143,7 +143,7 @@ def set_alpha(self, alpha): alpha : {0} The alpha value. """ - self.colorblending.call_action("setAlpha", self.layer_id, alpha) + self.color_blending.call_action("setAlpha", self.layer_id, alpha) @validate(Constant(Colormap), Boolean()) def set_colormap(self, colormap, invert=False): @@ -273,7 +273,7 @@ def layer_list(self): Returns ------- - list of :obj:`carta.colorblending.Layer` + list of :obj:`carta.color_blending.Layer` A list of Layer objects. """ layer_count = self.get_value("frames.length") diff --git a/carta/image_base.py b/carta/image_base.py index 05ed39b..51a19b4 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -1,6 +1,6 @@ """This module contains the shared base class for image-view items (frame-backed images and color blendings). -The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.colorblending.ColorBlending` can share a common protocol without one having to import the other. +The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.color_blending.ColorBlending` can share a common protocol without one having to import the other. """ diff --git a/carta/session.py b/carta/session.py index e10a50f..f6aa220 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,7 +10,7 @@ import posixpath from .image import Image -from .colorblending import ColorBlending +from .color_blending import ColorBlending from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol @@ -553,7 +553,7 @@ def open_as_color_blending(self, files): Returns ------- - :obj:`carta.colorblending.ColorBlending` + :obj:`carta.color_blending.ColorBlending` The new color blending object. """ images = self.open_images(files, append=False) @@ -632,7 +632,7 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= image_view_order : integer, optional The index of the item in the image list. Returns whichever concrete wrapper (:obj:`carta.image.Image` - or :obj:`carta.colorblending.ColorBlending`) matches the + or :obj:`carta.color_blending.ColorBlending`) matches the entry type at that position. Raises :obj:`NotImplementedError` for any future entry type that is not yet wrapped on the Python side. @@ -723,7 +723,7 @@ def active_image(self): Returns ------- - :obj:`carta.image.Image` or :obj:`carta.colorblending.ColorBlending` + :obj:`carta.image.Image` or :obj:`carta.color_blending.ColorBlending` The currently active image-view item. Raises @@ -753,7 +753,7 @@ def create_color_blending(self): Returns ------- - :obj:`carta.colorblending.ColorBlending` + :obj:`carta.color_blending.ColorBlending` The new color blending object. Raises diff --git a/docs/source/carta.rst b/docs/source/carta.rst index f1e13f5..ab20c1d 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -17,10 +17,10 @@ carta.browser module :undoc-members: :show-inheritance: -carta.colorblending module --------------------------- +carta.color_blending module +--------------------------- -.. automodule:: carta.colorblending +.. automodule:: carta.color_blending :members: :undoc-members: :show-inheritance: @@ -152,4 +152,3 @@ carta.wcs_overlay module :members: :undoc-members: :show-inheritance: - diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 00315b8..52170b8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -189,7 +189,7 @@ Helper methods on the session object open images in the frontend and return imag Inspecting the list of open images ---------------------------------- -The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.colorblending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. +The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.color_blending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. .. figure:: images/image_list.jpg :alt: CARTA frontend image list panel showing frame-backed images and a color blending entry. @@ -200,7 +200,7 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba .. code-block:: python from carta.image import Image - from carta.colorblending import ColorBlending + from carta.color_blending import ColorBlending # All open image-view items, in display order items = session.image_list() diff --git a/tests/test_colorblending.py b/tests/test_color_blending.py similarity index 63% rename from tests/test_colorblending.py rename to tests/test_color_blending.py index f34dd4c..6903ee5 100644 --- a/tests/test_colorblending.py +++ b/tests/test_color_blending.py @@ -1,6 +1,6 @@ import pytest -from carta.colorblending import ColorBlending, Layer +from carta.color_blending import ColorBlending, Layer from carta.constants import Colormap as CM from carta.constants import ColormapSet as CMS from carta.constants import ImageType @@ -12,23 +12,23 @@ @pytest.fixture -def colorblending(session): +def color_blending(session): return ColorBlending(session, 0) @pytest.fixture -def layer(colorblending): - return Layer(colorblending, 1) +def layer(color_blending): + return Layer(color_blending, 1) @pytest.fixture -def cb_get_value(colorblending, mock_get_value): - return mock_get_value(colorblending) +def cb_get_value(color_blending, mock_get_value): + return mock_get_value(color_blending) @pytest.fixture -def cb_call_action(colorblending, mock_call_action): - return mock_call_action(colorblending) +def cb_call_action(color_blending, mock_call_action): + return mock_call_action(color_blending) @pytest.fixture @@ -53,28 +53,28 @@ def session_get_value(session, mock_get_value): @pytest.fixture def cb_property(mock_property): - return mock_property("carta.colorblending.ColorBlending") + return mock_property("carta.color_blending.ColorBlending") @pytest.fixture def layer_property(mock_property): - return mock_property("carta.colorblending.Layer") + return mock_property("carta.color_blending.Layer") # TESTS — Layer -def test_layer_from_list(colorblending): - layers = Layer.from_list(colorblending, [5, 6, 7]) +def test_layer_from_list(color_blending): + layers = Layer.from_list(color_blending, [5, 6, 7]) assert [ly.layer_id for ly in layers] == [5, 6, 7] - assert all(ly.colorblending is colorblending for ly in layers) + assert all(ly.color_blending is color_blending for ly in layers) -def test_layer_repr_healthy(session, colorblending, layer_property, mocker): +def test_layer_repr_healthy(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=2) layer_property("file_id", 42) layer_property("file_name", "layer1.fits") - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " "file_name='layer1.fits')" @@ -83,7 +83,7 @@ def test_layer_repr_healthy(session, colorblending, layer_property, mocker): def test_layer_repr_closed_when_frame_not_in_image_list( - session, colorblending, layer_property, mocker + session, color_blending, layer_property, mocker ): layer_property("file_id", 42) mocker.patch.object( @@ -91,20 +91,20 @@ def test_layer_repr_closed_when_frame_not_in_image_list( "_find_image_view_order", side_effect=RuntimeError("not in image list"), ) - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "[Closed] Layer(image_view_order=None, color_blending_id=0, " "layer_id=3)" ) -def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): +def test_layer_repr_closed_when_frame_is_gone(session, color_blending, mocker): mocker.patch( - "carta.colorblending.Layer.file_id", + "carta.color_blending.Layer.file_id", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("frame is gone"), ) - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "[Closed] Layer(image_view_order=None, color_blending_id=0, " "layer_id=3)" @@ -112,16 +112,16 @@ def test_layer_repr_closed_when_frame_is_gone(session, colorblending, mocker): def test_layer_repr_closed_when_file_name_read_fails( - session, colorblending, layer_property, mocker + session, color_blending, layer_property, mocker ): mocker.patch.object(session, "_find_image_view_order", return_value=2) layer_property("file_id", 42) mocker.patch( - "carta.colorblending.Layer.file_name", + "carta.color_blending.Layer.file_name", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("file_name read failed"), ) - r = repr(Layer(colorblending, 3)) + r = repr(Layer(color_blending, 3)) assert r == ( "[Closed] Layer(image_view_order=2, color_blending_id=0, " "layer_id=3)" @@ -138,15 +138,15 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") -def test_layer_image_view_order(session, colorblending, layer_property, mocker): +def test_layer_image_view_order(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=7) layer_property("file_id", 42) - assert Layer(colorblending, 3).image_view_order == 7 + assert Layer(color_blending, 3).image_view_order == 7 find.assert_called_once_with(ImageType.FRAME, 42) def test_layer_image_view_order_raises_when_frame_not_in_image_list( - session, colorblending, layer_property, mocker + session, color_blending, layer_property, mocker ): layer_property("file_id", 42) mocker.patch.object( @@ -155,19 +155,19 @@ def test_layer_image_view_order_raises_when_frame_not_in_image_list( side_effect=RuntimeError("not in image list"), ) with pytest.raises(RuntimeError): - Layer(colorblending, 3).image_view_order + Layer(color_blending, 3).image_view_order @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) +def test_layer_set_alpha_valid(color_blending, alpha, cb_call_action): + Layer(color_blending, 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): +def test_layer_set_alpha_invalid(color_blending, alpha): with pytest.raises(CartaValidationFailed): - Layer(colorblending, 2).set_alpha(alpha) + Layer(color_blending, 2).set_alpha(alpha) @pytest.mark.parametrize("invert", [True, False]) @@ -187,65 +187,65 @@ def test_layer_set_colormap_invalid_colormap(layer, layer_call_action): # TESTS — ColorBlending basics -def test_colorblending_init(session): - colorblending = ColorBlending(session, 3) - assert colorblending.color_blending_id == 3 +def test_color_blending_init(session): + color_blending = ColorBlending(session, 3) + assert color_blending.color_blending_id == 3 expected = "imageViewConfigStore.colorBlendingImageMap[3]" - assert colorblending._base_path == expected - assert colorblending._frame == Macro( + assert color_blending._base_path == expected + assert color_blending._frame == Macro( "", "imageViewConfigStore.colorBlendingImageMap[3]" ) -def test_colorblending_repr_healthy(session, colorblending, cb_property, mocker): +def test_color_blending_repr_healthy(session, color_blending, cb_property, mocker): mocker.patch.object(session, "call_action", return_value=2) cb_property("file_name", "Color Blending 1") - r = repr(colorblending) + r = repr(color_blending) assert r == ( "ColorBlending(image_view_order=2, color_blending_id=0, " "file_name='Color Blending 1')" ) -def test_colorblending_repr_closed_when_not_in_image_list( - session, colorblending, mocker +def test_color_blending_repr_closed_when_not_in_image_list( + session, color_blending, mocker ): mocker.patch.object( session, "call_action", side_effect=RuntimeError("not in image list"), ) - r = repr(colorblending) + r = repr(color_blending) assert r == ( "[Closed] ColorBlending(image_view_order=None, color_blending_id=0)" ) -def test_colorblending_repr_closed_when_backing_entry_is_gone( - session, colorblending, mocker +def test_color_blending_repr_closed_when_backing_entry_is_gone( + session, color_blending, mocker ): mocker.patch.object(session, "call_action", return_value=2) mocker.patch( - "carta.colorblending.ColorBlending.file_name", + "carta.color_blending.ColorBlending.file_name", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("color blending is gone"), ) - r = repr(colorblending) + r = repr(color_blending) assert r == ( "[Closed] ColorBlending(image_view_order=2, color_blending_id=0)" ) -def test_colorblending_file_name(colorblending, cb_get_value): - colorblending.file_name +def test_color_blending_file_name(color_blending, cb_get_value): + color_blending.file_name cb_get_value.assert_called_with("filename") -def test_colorblending_image_view_order( - session, colorblending, session_call_action +def test_color_blending_image_view_order( + session, color_blending, session_call_action ): session_call_action.return_value = 2 - assert colorblending.image_view_order == 2 + assert color_blending.image_view_order == 2 session_call_action.assert_called_once_with( "imageViewConfigStore.getImageListIndex", ImageType.COLOR_BLENDING, @@ -254,47 +254,47 @@ def test_colorblending_image_view_order( ) -def test_colorblending_image_view_order_raises_when_missing( - session, colorblending, session_call_action +def test_color_blending_image_view_order_raises_when_missing( + session, color_blending, session_call_action ): session_call_action.return_value = -1 with pytest.raises(RuntimeError): - colorblending.image_view_order + color_blending.image_view_order -def test_colorblending_alpha(colorblending, cb_get_value): - colorblending.alpha +def test_color_blending_alpha(color_blending, cb_get_value): + color_blending.alpha cb_get_value.assert_called_with("alpha") -def test_colorblending_base_frame(colorblending, cb_get_value): +def test_color_blending_base_frame(color_blending, cb_get_value): cb_get_value.return_value = 42 - base_frame = colorblending._base_frame + base_frame = color_blending._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.session is color_blending.session assert base_frame.file_id == 42 -def test_colorblending_make_active(session, colorblending, session_call_action): +def test_color_blending_make_active(session, color_blending, session_call_action): # make_active must be driven by color_blending_id via setActiveImageById. # It must not depend on image_view_order (which is volatile). - colorblending.make_active() + color_blending.make_active() session_call_action.assert_called_with( "setActiveImageById", ImageType.COLOR_BLENDING, 0 ) -def test_colorblending_make_active_does_not_read_image_view_order( - session, colorblending, session_call_action, session_get_value +def test_color_blending_make_active_does_not_read_image_view_order( + session, color_blending, session_call_action, session_get_value ): - colorblending.make_active() + color_blending.make_active() for call in session_get_value.call_args_list: assert call.args != ("imageViewConfigStore.imageListSummary",) -def test_colorblending_layer_list_derived(session, mocker): +def test_color_blending_layer_list_derived(session, mocker): cb = ColorBlending(session, 3) # Simulate two layers from the frontend's computed frames array length. @@ -306,33 +306,33 @@ def test_colorblending_layer_list_derived(session, mocker): gv.assert_called_once_with("frames.length") -def test_colorblending_add_layer(colorblending, cb_call_action, image): - colorblending.add_layer(image) +def test_color_blending_add_layer(color_blending, cb_call_action, image): + color_blending.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 +def test_color_blending_delete_layer( + color_blending, cb_call_action, idx, expected_param ): - colorblending.delete_layer(idx) + color_blending.delete_layer(idx) cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) -def test_colorblending_delete_layer_rejects_base_layer( - colorblending, cb_call_action +def test_color_blending_delete_layer_rejects_base_layer( + color_blending, cb_call_action ): with pytest.raises(ValueError, match="The base layer cannot be deleted."): - colorblending.delete_layer(0) + color_blending.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 +def test_color_blending_set_layer( + color_blending, cb_call_action, image, idx, expected_param ): - colorblending.set_layer(image, idx) + color_blending.set_layer(image, idx) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame ) @@ -344,7 +344,7 @@ def __init__(self, lid, fid): self.file_id = fid -def test_colorblending_set_layer_sequence(session, colorblending, mocker): +def test_color_blending_set_layer_sequence(session, color_blending, mocker): # Prepare three existing layers with file_ids 10, 20, 30 mocker.patch.object( ColorBlending, @@ -352,15 +352,15 @@ def test_colorblending_set_layer_sequence(session, colorblending, mocker): return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) mocker.patch( - "carta.colorblending.ColorBlending.alpha", + "carta.color_blending.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") + del_layer = mocker.patch.object(color_blending, "delete_layer") + add_layer = mocker.patch.object(color_blending, "add_layer") set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.set_layer_sequence([0, 2, 1]) + color_blending.set_layer_sequence([0, 2, 1]) # Deletes all non-base layers (twice) then adds layers in specified order assert del_layer.call_count == 2 @@ -369,8 +369,8 @@ def test_colorblending_set_layer_sequence(session, colorblending, mocker): assert [call.args[1] for call in set_alpha.call_args_list] == [0.8, 0.2] -def test_colorblending_set_layer_sequence_noop_when_order_is_unchanged( - colorblending, mocker +def test_color_blending_set_layer_sequence_noop_when_order_is_unchanged( + color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -378,23 +378,23 @@ def test_colorblending_set_layer_sequence_noop_when_order_is_unchanged( return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], ) mocker.patch( - "carta.colorblending.ColorBlending.alpha", + "carta.color_blending.ColorBlending.alpha", new_callable=mocker.PropertyMock, side_effect=AssertionError("alpha should not be read"), ) - del_layer = mocker.patch.object(colorblending, "delete_layer") - add_layer = mocker.patch.object(colorblending, "add_layer") + del_layer = mocker.patch.object(color_blending, "delete_layer") + add_layer = mocker.patch.object(color_blending, "add_layer") set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.set_layer_sequence([0, 1, 2]) + color_blending.set_layer_sequence([0, 1, 2]) del_layer.assert_not_called() add_layer.assert_not_called() set_alpha.assert_not_called() -def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_supports_user_specified_subset_order( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -402,23 +402,23 @@ def test_colorblending_set_layer_sequence_supports_user_specified_subset_order( return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], ) mocker.patch( - "carta.colorblending.ColorBlending.alpha", + "carta.color_blending.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") + del_layer = mocker.patch.object(color_blending, "delete_layer") + add_layer = mocker.patch.object(color_blending, "add_layer") set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - colorblending.set_layer_sequence([0, 3, 1]) + color_blending.set_layer_sequence([0, 3, 1]) assert del_layer.call_count == 3 assert [call.args[0].file_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 +def test_color_blending_set_layer_sequence_rejects_missing_layer_index( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -427,14 +427,14 @@ def test_colorblending_set_layer_sequence_rejects_missing_layer_index( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([0, 4, 1]) + color_blending.set_layer_sequence([0, 4, 1]) assert "layer_indices [0, 4, 1]" in str(e.value) assert "[4]" in str(e.value) assert "0..3" in str(e.value) -def test_colorblending_set_layer_sequence_requires_base_layer_first( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_requires_base_layer_first( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -443,13 +443,13 @@ def test_colorblending_set_layer_sequence_requires_base_layer_first( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([2, 1]) + color_blending.set_layer_sequence([2, 1]) assert "layer_indices [2, 1]" in str(e.value) assert "must start with the base layer index 0" in str(e.value) -def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_rejects_duplicate_base_layer( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -458,13 +458,13 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_base_layer( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([0, 2, 0]) + color_blending.set_layer_sequence([0, 2, 0]) assert "layer_indices [0, 2, 0]" in str(e.value) assert "must contain the base layer index 0 only once" in str(e.value) -def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( - session, colorblending, mocker +def test_color_blending_set_layer_sequence_rejects_duplicate_non_base_layer( + session, color_blending, mocker ): mocker.patch.object( ColorBlending, @@ -473,94 +473,94 @@ def test_colorblending_set_layer_sequence_rejects_duplicate_non_base_layer( ) with pytest.raises(ValueError) as e: - colorblending.set_layer_sequence([0, 1, 1]) + color_blending.set_layer_sequence([0, 1, 1]) assert "layer_indices [0, 1, 1]" in str(e.value) assert "must not contain duplicate layer indices" in str(e.value) assert "[1]" in str(e.value) -def test_colorblending_set_center(colorblending, mocker): +def test_color_blending_set_center(color_blending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) - colorblending.set_center(1, 2) + color_blending.set_center(1, 2) base_frame.set_center.assert_called_once_with(1, 2) @pytest.mark.parametrize("size,axis", [(123, SA.X), ("123arcsec", SA.Y)]) -def test_colorblending_zoom_to_size(colorblending, mocker, size, axis): +def test_color_blending_zoom_to_size(color_blending, mocker, size, axis): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) - colorblending.zoom_to_size(size, axis) + color_blending.zoom_to_size(size, axis) base_frame.zoom_to_size.assert_called_once_with(size, axis) @pytest.mark.parametrize("size,axis", [("123px", SA.X), (123, "z")]) -def test_colorblending_zoom_to_size_invalid(colorblending, mocker, size, axis): +def test_color_blending_zoom_to_size_invalid(color_blending, mocker, size, axis): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) with pytest.raises(CartaValidationFailed): - colorblending.zoom_to_size(size, axis) + color_blending.zoom_to_size(size, axis) base_frame.zoom_to_size.assert_not_called() @pytest.mark.parametrize("zoom,absolute", [(2, True), (3.5, False)]) -def test_colorblending_set_zoom_level(colorblending, mocker, zoom, absolute): +def test_color_blending_set_zoom_level(color_blending, mocker, zoom, absolute): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( - "carta.colorblending.ColorBlending._base_frame", + "carta.color_blending.ColorBlending._base_frame", new_callable=mocker.PropertyMock, return_value=base_frame, ) - colorblending.set_zoom_level(zoom, absolute) + color_blending.set_zoom_level(zoom, absolute) base_frame.set_zoom_level.assert_called_once_with(zoom, absolute) -def test_colorblending_set_colormap_set(colorblending, cb_call_action): - colorblending.set_colormap_set(CMS.RAINBOW) +def test_color_blending_set_colormap_set(color_blending, cb_call_action): + color_blending.set_colormap_set(CMS.RAINBOW) cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) -def test_colorblending_set_alpha_valid(colorblending, mocker): - ly1 = mocker.create_autospec(Layer(colorblending, 1), instance=True) - ly2 = mocker.create_autospec(Layer(colorblending, 2), instance=True) +def test_color_blending_set_alpha_valid(color_blending, mocker): + ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) - colorblending.set_alpha([0.2, 0.8]) + color_blending.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): +def test_color_blending_set_alpha_invalid(color_blending, vals): with pytest.raises(CartaValidationFailed): - colorblending.set_alpha(vals) + color_blending.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) +def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): + ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) + ly2 = mocker.create_autospec(Layer(color_blending, 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) + color_blending.set_alpha(vals) @pytest.mark.parametrize( @@ -581,12 +581,12 @@ def test_colorblending_set_alpha_length_mismatch(colorblending, mocker, vals): ), ], ) -def test_colorblending_toggle_visibility_when_needed( - colorblending, cb_get_value, cb_call_action, getter, method, action, state +def test_color_blending_toggle_visibility_when_needed( + color_blending, 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) + getattr(color_blending, method)(state) cb_call_action.assert_called_with(action) @@ -608,17 +608,17 @@ def test_colorblending_toggle_visibility_when_needed( ), ], ) -def test_colorblending_toggle_visibility_noop( - colorblending, cb_get_value, cb_call_action, getter, method, action, state +def test_color_blending_toggle_visibility_noop( + color_blending, 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) + getattr(color_blending, method)(state) cb_call_action.assert_not_called() -def test_colorblending_close(session, colorblending, session_call_action): - colorblending.close() +def test_color_blending_close(session, color_blending, session_call_action): + color_blending.close() session_call_action.assert_called_with( - "imageViewConfigStore.removeColorBlending", colorblending._frame + "imageViewConfigStore.removeColorBlending", color_blending._frame ) diff --git a/tests/test_session.py b/tests/test_session.py index 91e6a96..0cdd436 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,7 +3,7 @@ import pytest from carta.image import Image -from carta.colorblending import ColorBlending +from carta.color_blending import ColorBlending from carta.util import CartaActionFailed, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol From e4c89bf9a3c02b0658353bd8a0794382fcf9a49c Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 21 May 2026 16:29:58 +0800 Subject: [PATCH 73/95] Remove set_layer_sequence method from ColorBlending class because it is considered as an unnecessay feature --- carta/color_blending.py | 77 ----------------------------------------- 1 file changed, 77 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index b1d20a1..8cb7564 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -317,83 +317,6 @@ def set_layer(self, image, layer_index): """ self.call_action("setSelectedFrame", layer_index - 1, image._frame) - @validate(IterableOf(Number(0, None), min_size=1)) - def set_layer_sequence(self, layer_indices): - """Set which layers are included in the color blending and in what - order. - - Parameters - ---------- - layer_indices : {0} - The layer indices to keep, in the desired order. The first index - must be the base layer (index = 0). Existing alpha values are - preserved. - """ - layer_indices = list(layer_indices) - current_layers = self.layer_list() - max_current_layer_index = len(current_layers) - 1 - invalid_layer_indices = [ - layer_index - for layer_index in layer_indices - if layer_index > max_current_layer_index - ] - if invalid_layer_indices: - raise ValueError( - f"layer_indices {layer_indices!r} contains non-existent layer " - f"indices {invalid_layer_indices!r}; available layer indices " - f"are 0..{max_current_layer_index}." - ) - - if layer_indices[0] != 0: - raise ValueError( - f"layer_indices {layer_indices!r} must start with the base " - "layer index 0." - ) - - if 0 in layer_indices[1:]: - raise ValueError( - f"layer_indices {layer_indices!r} must contain the base layer " - "index 0 only once, as the first index." - ) - - if len(layer_indices) != len(set(layer_indices)): - duplicate_layer_indices = sorted( - { - layer_index - for layer_index in layer_indices - if layer_indices.count(layer_index) > 1 - } - ) - raise ValueError( - f"layer_indices {layer_indices!r} must not contain duplicate " - f"layer indices; duplicates were {duplicate_layer_indices!r}." - ) - - current_layer_indices = list(range(len(current_layers))) - if list(layer_indices) == current_layer_indices: - return - - current_alpha_values = self.alpha - target_layer_states = [ - ( - Image(self.session, current_layers[layer_index].file_id), - current_alpha_values[layer_index], - ) - for layer_index in layer_indices[1:] - ] - - # Delete all layers except the base layer - for _ in current_layers[1:]: - # Delete layer at index 1 (the first non-base layer); - # after deletion, the previous layer at index 2 shifts to index 1 - self.delete_layer(1) - - for target_layer_index, (image, alpha) in enumerate( - target_layer_states, start=1 - ): - self.add_layer(image) - Layer(self, target_layer_index).set_alpha(alpha) - # NAVIGATION @validate(Coordinate(), Coordinate()) From c411b4c2f8cccf03661d7a25b34bf14d4742ca57 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 21 May 2026 17:04:18 +0800 Subject: [PATCH 74/95] Remove set_layer_sequence tests after removing the method from ColorBlending class --- tests/test_color_blending.py | 141 ----------------------------------- 1 file changed, 141 deletions(-) diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 6903ee5..52b73b5 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -338,147 +338,6 @@ def test_color_blending_set_layer( ) -class _L: - def __init__(self, lid, fid): - self.layer_id = lid - self.file_id = fid - - -def test_color_blending_set_layer_sequence(session, color_blending, mocker): - # Prepare three existing layers with file_ids 10, 20, 30 - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - mocker.patch( - "carta.color_blending.ColorBlending.alpha", - new_callable=mocker.PropertyMock, - return_value=[1.0, 0.2, 0.8], - ) - del_layer = mocker.patch.object(color_blending, "delete_layer") - add_layer = mocker.patch.object(color_blending, "add_layer") - set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - - color_blending.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.file_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_color_blending_set_layer_sequence_noop_when_order_is_unchanged( - color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - mocker.patch( - "carta.color_blending.ColorBlending.alpha", - new_callable=mocker.PropertyMock, - side_effect=AssertionError("alpha should not be read"), - ) - del_layer = mocker.patch.object(color_blending, "delete_layer") - add_layer = mocker.patch.object(color_blending, "add_layer") - set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - - color_blending.set_layer_sequence([0, 1, 2]) - - del_layer.assert_not_called() - add_layer.assert_not_called() - set_alpha.assert_not_called() - - -def test_color_blending_set_layer_sequence_supports_user_specified_subset_order( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], - ) - mocker.patch( - "carta.color_blending.ColorBlending.alpha", - new_callable=mocker.PropertyMock, - return_value=[1.0, 0.2, 0.8, 0.4], - ) - del_layer = mocker.patch.object(color_blending, "delete_layer") - add_layer = mocker.patch.object(color_blending, "add_layer") - set_alpha = mocker.patch.object(Layer, "set_alpha", autospec=True) - - color_blending.set_layer_sequence([0, 3, 1]) - - assert del_layer.call_count == 3 - assert [call.args[0].file_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_color_blending_set_layer_sequence_rejects_missing_layer_index( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30), _L(3, 40)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([0, 4, 1]) - assert "layer_indices [0, 4, 1]" in str(e.value) - assert "[4]" in str(e.value) - assert "0..3" in str(e.value) - - -def test_color_blending_set_layer_sequence_requires_base_layer_first( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([2, 1]) - assert "layer_indices [2, 1]" in str(e.value) - assert "must start with the base layer index 0" in str(e.value) - - -def test_color_blending_set_layer_sequence_rejects_duplicate_base_layer( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([0, 2, 0]) - assert "layer_indices [0, 2, 0]" in str(e.value) - assert "must contain the base layer index 0 only once" in str(e.value) - - -def test_color_blending_set_layer_sequence_rejects_duplicate_non_base_layer( - session, color_blending, mocker -): - mocker.patch.object( - ColorBlending, - "layer_list", - return_value=[_L(0, 10), _L(1, 20), _L(2, 30)], - ) - - with pytest.raises(ValueError) as e: - color_blending.set_layer_sequence([0, 1, 1]) - assert "layer_indices [0, 1, 1]" in str(e.value) - assert "must not contain duplicate layer indices" in str(e.value) - assert "[1]" in str(e.value) - - def test_color_blending_set_center(color_blending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( From 0d8c8bc285f770efcabf389f21abc0c1419e1453 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 09:34:28 +0800 Subject: [PATCH 75/95] Rename set_layer to set_layer_image and swap parameter order to put layer_index before image for consistency --- carta/color_blending.py | 12 ++++++------ tests/test_color_blending.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 8cb7564..4cf8afd 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -303,17 +303,17 @@ def delete_layer(self, layer_index): raise ValueError("The base layer cannot be deleted.") self.call_action("deleteSelectedFrame", layer_index - 1) - @validate(InstanceOf(Image), Number(1, None)) - def set_layer(self, image, layer_index): - """Set a layer at a specified index in the color blending. + @validate(Number(1, None), InstanceOf(Image)) + def set_layer_image(self, layer_index, image): + """Set the image for a layer at a specified index in the color blending. Parameters ---------- - image : {0} - The image to set. - layer_index : {1} + layer_index : {0} The layer index. The base layer (layer_index = 0) cannot be set. + image : {1} + The image to set. """ self.call_action("setSelectedFrame", layer_index - 1, image._frame) diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 52b73b5..6ee607b 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -329,10 +329,10 @@ def test_color_blending_delete_layer_rejects_base_layer( @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) -def test_color_blending_set_layer( +def test_color_blending_set_layer_image( color_blending, cb_call_action, image, idx, expected_param ): - color_blending.set_layer(image, idx) + color_blending.set_layer_image(idx, image) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame ) From 955ff8cb6c822c5361d0dbce6aeae644c461b2a2 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 11:19:29 +0800 Subject: [PATCH 76/95] Refactor ImageBase to use class-level IMAGE_TYPE constant and add image_class registry method for dynamic image-view wrapper instantiation --- carta/color_blending.py | 2 +- carta/image.py | 2 +- carta/image_base.py | 39 ++++++++++--- carta/session.py | 114 ++++++++++++++++++------------------- tests/test_image.py | 69 +++------------------- tests/test_session.py | 123 ++++++++++++++++++++++++++-------------- 6 files changed, 180 insertions(+), 169 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 4cf8afd..f1e45a4 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -178,7 +178,7 @@ class ColorBlending(ImageBase, BasePathMixin): The id of the backing ``ColorBlendingStore`` on the frontend. """ - _image_type = ImageType.COLOR_BLENDING + IMAGE_TYPE = ImageType.COLOR_BLENDING def __init__(self, session, color_blending_id): super().__init__(session) diff --git a/carta/image.py b/carta/image.py index d8deb17..e40aed4 100644 --- a/carta/image.py +++ b/carta/image.py @@ -47,7 +47,7 @@ class Image(ImageBase, BasePathMixin): Functions for manipulating regions associated with this image. """ - _image_type = ImageType.FRAME + IMAGE_TYPE = ImageType.FRAME def __init__(self, session, file_id): super().__init__(session) diff --git a/carta/image_base.py b/carta/image_base.py index 51a19b4..81f0a0b 100644 --- a/carta/image_base.py +++ b/carta/image_base.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from .constants import ImageType +from .validation import Constant, validate class ImageBase(ABC): @@ -20,7 +21,13 @@ class ImageBase(ABC): The session object associated with this image-view item. """ - _image_type: ImageType = None + CUSTOM_CLASS = {} + """Mapping of image-view types to their concrete wrapper classes.""" + + def __init_subclass__(cls, **kwargs): + """Register concrete image-view wrapper subclasses by image type.""" + super().__init_subclass__(**kwargs) + ImageBase.CUSTOM_CLASS[cls.IMAGE_TYPE] = cls def __init__(self, session): self.session = session @@ -31,11 +38,29 @@ def _stable_id(self): """The stable identifier of this image-view item.""" raise NotImplementedError # pragma: no cover - def _require_image_type(self): - if self._image_type is None: + @classmethod + @validate(Constant(ImageType)) + def image_class(cls, image_type): + """The image class associated with an image-view type. + + Parameters + ---------- + image_type : {0} + The image-view type. + + Returns + ------- + class object + The concrete image-view wrapper class. + """ + image_type = ImageType(image_type) + image_class = cls.CUSTOM_CLASS.get(image_type) + if image_class is None: raise NotImplementedError( - "Subclasses must define _image_type." + f"No ImageBase subclass is registered for image-view type " + f"{image_type!r}." ) + return image_class @property def image_view_order(self): @@ -51,14 +76,12 @@ def image_view_order(self): RuntimeError If no matching entry exists in the image list. """ - self._require_image_type() return self.session._find_image_view_order( - self._image_type, self._stable_id + self.IMAGE_TYPE, self._stable_id ) def make_active(self): """Make this the active image-view item.""" - self._require_image_type() self.session.call_action( - "setActiveImageById", self._image_type, self._stable_id + "setActiveImageById", self.IMAGE_TYPE, self._stable_id ) diff --git a/carta/session.py b/carta/session.py index f6aa220..2706047 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,11 +10,12 @@ import posixpath from .image import Image +from .image_base import ImageBase from .color_blending import ColorBlending from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaActionFailed, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, cached, Point as Pt +from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay @@ -576,17 +577,14 @@ def image_list(self): summary = self.get_value("imageViewConfigStore.imageListSummary") result = [] for order, entry in enumerate(summary): - entry_type = entry["type"] - if entry_type == ImageType.FRAME: - result.append(Image(self, entry["id"])) - elif entry_type == ImageType.COLOR_BLENDING: - result.append(ColorBlending(self, entry["id"])) - else: - raise NotImplementedError( - f"image_list encountered an unsupported image-view " - f"entry at order {order} with type {entry_type!r}; " - "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " - "entries are currently wrapped." + try: + result.append( + ImageBase.image_class(entry["type"])(self, entry["id"]) + ) + except (CartaValidationFailed, NotImplementedError) as e: + print( + f"Skipping unsupported image-view entry at order {order}: " + f"{entry!r}: {e}" ) return result @@ -663,56 +661,64 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= "file_id": file_id, "color_blending_id": color_blending_id, } - given = [key for key, val in provided.items() if val is not None] - if len(given) != 1: - given_values = { - key: val for key, val in provided.items() if val is not None - } + provided_values = { + key: value + for key, value in provided.items() + if value is not None + } + if len(provided_values) != 1: raise ValueError( "image_by_id requires exactly one of the keyword arguments " "`image_view_order`, `file_id`, or `color_blending_id`; " - f"got {len(given)} with values {given_values!r}." + f"got {len(provided_values)} with values {provided_values!r}." ) - summary = self.get_value("imageViewConfigStore.imageListSummary") - if image_view_order is not None: - if image_view_order < 0 or image_view_order >= len(summary): + if image_view_order < 0: raise IndexError( f"image_view_order {image_view_order} is out of range " - f"for an image list of length {len(summary)}." + "for the image list." ) - entry = summary[image_view_order] - entry_type = entry["type"] - entry_id = entry["id"] - if entry_type == ImageType.FRAME: - return Image(self, entry_id) - if entry_type == ImageType.COLOR_BLENDING: - return ColorBlending(self, entry_id) - raise NotImplementedError( - f"image_by_id encountered an unsupported image-view entry " - f"at order {image_view_order} with type {entry_type!r}; " - "only Image (FRAME) and ColorBlending (COLOR_BLENDING) " - "entries are currently wrapped." - ) + try: + entry = self.get_value( + "imageViewConfigStore.imageListSummary" + f"[{image_view_order}]" + ) + except (CartaActionFailed, CartaBadResponse) as e: + raise IndexError( + f"image_view_order {image_view_order} is out of range " + "for the image list." + ) from e + return ImageBase.image_class(entry["type"])(self, entry["id"]) if file_id is not None: - for entry in summary: - if entry["type"] == ImageType.FRAME and entry["id"] == file_id: - return Image(self, file_id) - raise RuntimeError( - f"No frame-backed image with file_id={file_id} is open." + try: + resolved_file_id = self.get_value( + f"frameMap[{file_id}]", + return_path="frameInfo.fileId", + ) + except (CartaActionFailed, CartaBadResponse) as e: + raise RuntimeError( + f"No frame-backed image with file_id={file_id} is open." + ) from e + return ImageBase.image_class(ImageType.FRAME)( + self, resolved_file_id ) # color_blending_id is not None - for entry in summary: - if ( - entry["type"] == ImageType.COLOR_BLENDING - and entry["id"] == color_blending_id - ): - return ColorBlending(self, color_blending_id) - raise RuntimeError( - f"No color blending with color_blending_id={color_blending_id} is open." + try: + resolved_color_blending_id = self.get_value( + f"imageViewConfigStore.colorBlendingImageMap" + f"[{color_blending_id}]", + return_path="id", + ) + except (CartaActionFailed, CartaBadResponse) as e: + raise RuntimeError( + f"No color blending with color_blending_id={color_blending_id} " + "is open." + ) from e + return ImageBase.image_class(ImageType.COLOR_BLENDING)( + self, resolved_color_blending_id ) def active_image(self): @@ -733,16 +739,8 @@ def active_image(self): the Python side. """ active = self.get_value("activeImage") - active_type = active["type"] - active_id = active["store"]["id"] - if active_type == ImageType.FRAME: - return Image(self, active_id) - if active_type == ImageType.COLOR_BLENDING: - return ColorBlending(self, active_id) - raise NotImplementedError( - f"active_image encountered an unsupported image-view type " - f"{active_type!r}; only Image (FRAME) and ColorBlending " - "(COLOR_BLENDING) entries are currently wrapped." + return ImageBase.image_class(active["type"])( + self, active["store"]["id"] ) # COLOR BLENDING diff --git a/tests/test_image.py b/tests/test_image.py index 7f2a969..69a3d13 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -122,78 +122,27 @@ def test_image_base_is_abstract(session): def test_image_base_make_active_uses_subclass_ids(session, session_call_action): # Verify the shared ImageBase.make_active dispatches setActiveImageById - # with the subclass's _image_type and _stable_id exactly once. - class Dummy(ImageBase): - _image_type = ImageType.FRAME - - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ - - @property - def _stable_id(self): - return self._id - - Dummy(session, 42).make_active() + # with Image.IMAGE_TYPE and its stable ID exactly once. + Image(session, 42).make_active() session_call_action.assert_called_once_with( "setActiveImageById", ImageType.FRAME, 42 ) def test_image_base_image_view_order_uses_subclass_ids(session, mocker): - class Dummy(ImageBase): - _image_type = ImageType.COLOR_BLENDING - - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ - - @property - def _stable_id(self): - return self._id - find = mocker.patch.object(session, "_find_image_view_order", return_value=5) - assert Dummy(session, 42).image_view_order == 5 - find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) - - -def test_image_base_image_type_required_for_make_active( - session, session_call_action -): - class Dummy(ImageBase): - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ + from carta.color_blending import ColorBlending - @property - def _stable_id(self): - return self._id - - with pytest.raises( - NotImplementedError, match="Subclasses must define _image_type" - ): - Dummy(session, 42).make_active() - - session_call_action.assert_not_called() - - -def test_image_base_image_type_required_for_image_view_order(session, mocker): - class Dummy(ImageBase): - def __init__(self, session, id_): - super().__init__(session) - self._id = id_ + assert ColorBlending(session, 42).image_view_order == 5 + find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) - @property - def _stable_id(self): - return self._id - find = mocker.patch.object(session, "_find_image_view_order") +def test_image_base_subclass_requires_image_type(): with pytest.raises( - NotImplementedError, match="Subclasses must define _image_type" + AttributeError, match="has no attribute 'IMAGE_TYPE'" ): - Dummy(session, 42).image_view_order - - find.assert_not_called() + class Dummy(ImageBase): + pass def test_image_view_order_uses_find_image_view_order(session, mocker, image): diff --git a/tests/test_session.py b/tests/test_session.py index 0cdd436..40f65cd 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,7 @@ from carta.image import Image from carta.color_blending import ColorBlending -from carta.util import CartaActionFailed, CartaValidationFailed, Macro, Point as Pt +from carta.util import CartaActionFailed, CartaBadResponse, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol # FIXTURES @@ -138,13 +138,20 @@ def test_image_list_heterogeneous(session, get_value): assert isinstance(images[2], Image) and images[2].file_id == 20 -def test_image_list_raises_on_pv_preview(session, get_value): +def test_image_list_skips_unsupported_image_type(session, get_value, capsys): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.PV_PREVIEW, "id": -2}, + {"type": 99, "id": 11}, + {"type": ImageType.FRAME, "id": 20}, ] - with pytest.raises(NotImplementedError): - session.image_list() + + images = session.image_list() + + assert [image.file_id for image in images] == [10, 20] + assert ( + "Skipping unsupported image-view entry at order 1" + in capsys.readouterr().out + ) def test_image_list_empty(session, get_value): @@ -185,16 +192,6 @@ def test_find_image_view_order_raises_when_missing(session, call_action): # session.image_by_id -@pytest.fixture -def summary(get_value): - get_value.return_value = [ - {"type": ImageType.FRAME, "id": 10}, - {"type": ImageType.COLOR_BLENDING, "id": 7}, - {"type": ImageType.FRAME, "id": 20}, - ] - return get_value - - def test_image_by_id_requires_exactly_one_keyword(session, get_value): # Zero keywords -> ValueError with all three names listed. with pytest.raises(ValueError) as e: @@ -214,69 +211,113 @@ def test_image_by_id_rejects_positional(session): session.image_by_id(0) -def test_image_by_id_by_image_view_order(session, summary): - img = session.image_by_id(image_view_order=0) - assert isinstance(img, Image) - assert img.file_id == 10 +@pytest.mark.parametrize( + "entry,expected_type,expected_id", + [ + ({"type": ImageType.FRAME, "id": 10}, Image, 10), + ( + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ColorBlending, + 7, + ), + ], +) +def test_image_by_id_by_image_view_order( + session, get_value, entry, expected_type, expected_id +): + get_value.return_value = entry - cb = session.image_by_id(image_view_order=1) - assert isinstance(cb, ColorBlending) - assert cb.color_blending_id == 7 + img = session.image_by_id(image_view_order=0) + assert isinstance(img, expected_type) + assert ( + img.file_id if expected_type is Image else img.color_blending_id + ) == expected_id + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary[0]" + ) - img2 = session.image_by_id(image_view_order=2) - assert isinstance(img2, Image) - assert img2.file_id == 20 +def test_image_by_id_by_image_view_order_out_of_range(session, get_value): + get_value.side_effect = CartaBadResponse("undefined") -def test_image_by_id_by_image_view_order_out_of_range(session, summary): with pytest.raises(IndexError): session.image_by_id(image_view_order=99) + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary[99]" + ) + def test_image_by_id_by_image_view_order_raises_on_unsupported_type( session, get_value ): - get_value.return_value = [ - {"type": ImageType.PV_PREVIEW, "id": -2}, - ] + get_value.return_value = {"type": ImageType.PV_PREVIEW, "id": -2} with pytest.raises(NotImplementedError): session.image_by_id(image_view_order=0) -def test_image_by_id_by_file_id(session, summary): +def test_image_by_id_by_file_id(session, get_value): + get_value.return_value = 20 + img = session.image_by_id(file_id=20) assert isinstance(img, Image) assert img.file_id == 20 + get_value.assert_called_once_with( + "frameMap[20]", + return_path="frameInfo.fileId", + ) -def test_image_by_id_by_file_id_no_cross_type_fallback(session, summary): - # The summary contains a COLOR_BLENDING entry with id=7, but no FRAME - # with that id, so image_by_id(file_id=7) must raise. +def test_image_by_id_by_file_id_no_cross_type_fallback(session, get_value): + get_value.side_effect = CartaBadResponse("undefined") + with pytest.raises(RuntimeError): session.image_by_id(file_id=7) -def test_image_by_id_by_color_blending_id(session, summary): +def test_image_by_id_by_color_blending_id(session, get_value): + get_value.return_value = 7 + cb = session.image_by_id(color_blending_id=7) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 + get_value.assert_called_once_with( + "imageViewConfigStore.colorBlendingImageMap[7]", + return_path="id", + ) + +def test_image_by_id_by_color_blending_id_no_cross_type_fallback( + session, get_value +): + get_value.side_effect = CartaBadResponse("undefined") -def test_image_by_id_by_color_blending_id_no_cross_type_fallback(session, summary): - # The summary contains a FRAME with id=10, but no COLOR_BLENDING with - # that id, so image_by_id(color_blending_id=10) must raise. with pytest.raises(RuntimeError): session.image_by_id(color_blending_id=10) -def test_image_by_id_single_round_trip(session, summary): +def test_image_by_id_uses_targeted_frontend_lookups(session, get_value): + get_value.side_effect = [ + {"type": ImageType.FRAME, "id": 10}, + 10, + 7, + ] + session.image_by_id(image_view_order=0) session.image_by_id(file_id=10) session.image_by_id(color_blending_id=7) - assert summary.call_count == 3 - for call_ in summary.call_args_list: - assert call_.args == ("imageViewConfigStore.imageListSummary",) + + assert get_value.call_args_list == [ + call( + "imageViewConfigStore.imageListSummary[0]" + ), + call("frameMap[10]", return_path="frameInfo.fileId"), + call( + "imageViewConfigStore.colorBlendingImageMap[7]", + return_path="id", + ), + ] # session.active_image From 51e2c4881f32b828ac1098a2c4b55224a695e54a Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 21:48:53 +0800 Subject: [PATCH 77/95] Add False parameter to setSpatialReference action call to disable color blending alert when making image spatial reference --- carta/image.py | 2 +- tests/test_image.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/carta/image.py b/carta/image.py index e40aed4..d635911 100644 --- a/carta/image.py +++ b/carta/image.py @@ -256,7 +256,7 @@ def polarizations(self): def make_spatial_reference(self): """Make this image the spatial reference.""" - self.session.call_action("setSpatialReference", self._frame) + self.session.call_action("setSpatialReference", self._frame, False) @validate(Boolean()) def set_spatial_matching(self, state): diff --git a/tests/test_image.py b/tests/test_image.py index 69a3d13..33ef995 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -115,6 +115,15 @@ def test_make_active(image, session_call_action): ) +def test_make_spatial_reference_disables_color_blending_alert( + image, session_call_action +): + image.make_spatial_reference() + session_call_action.assert_called_once_with( + "setSpatialReference", image._frame, False + ) + + def test_image_base_is_abstract(session): with pytest.raises(TypeError, match=r"abstract method.*_stable_id"): ImageBase(session) From d46311fa392406b3d1496e9f07ff4aead444b2bb Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 22:22:58 +0800 Subject: [PATCH 78/95] Add Layer.delete method and update ColorBlending.delete_layer to handle base layer deletion by promoting next layer or closing color blending --- carta/color_blending.py | 29 +++++++++++--- docs/source/quickstart.rst | 32 +++++++++------ tests/test_color_blending.py | 75 +++++++++++++++++++++++++++++++++--- 3 files changed, 114 insertions(+), 22 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index f1e45a4..206a24f 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -12,6 +12,8 @@ IterableOf, Number, Size, + Attr, + Evaluate, validate, ) @@ -134,6 +136,10 @@ def file_id(self): """ return self.get_value("frameInfo.fileId") + def delete(self): + """Delete this layer from its parent color blending.""" + self.color_blending.delete_layer(self.layer_id) + @validate(Number(0, 1)) def set_alpha(self, alpha): """Set the alpha value for the layer in the color blending. @@ -248,6 +254,11 @@ def alpha(self): """ return self.get_value("alpha") + @property + def depth(self): + """The number of layers in the color blending.""" + return self.get_value("frames.length") + @validate(IterableOf(Number(0, 1))) def set_alpha(self, alpha_list): """Set the alpha value for the color blending layers. @@ -276,8 +287,7 @@ def layer_list(self): list of :obj:`carta.color_blending.Layer` A list of Layer objects. """ - layer_count = self.get_value("frames.length") - return Layer.from_list(self, list(range(layer_count))) + return Layer.from_list(self, list(range(self.depth))) def add_layer(self, image): """Add a new layer to the color blending. @@ -289,18 +299,25 @@ def add_layer(self, image): """ self.call_action("addSelectedFrame", image._frame) - @validate(Number(0, None)) + @validate(Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1)) def delete_layer(self, layer_index): """Delete a layer from the color blending. Parameters ---------- layer_index : {0} - The layer index. The base layer (layer_index = 0) cannot - be deleted. + The layer index. If the base layer (layer_index = 0) is deleted, + the next layer becomes the spatial reference. If it is the only + layer, the color blending is closed. """ if layer_index == 0: - raise ValueError("The base layer cannot be deleted.") + layers = self.layer_list() + if len(layers) == 1: + self.close() + return + + Image(self.session, layers[1].file_id).make_spatial_reference() + return self.call_action("deleteSelectedFrame", layer_index - 1) @validate(Number(1, None), InstanceOf(Image)) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 52170b8..73d2d18 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -329,18 +329,24 @@ Manipulate properties of the color blending object and the underlying layers: # Or set alpha for all layers at once cb.set_alpha([0.7, 0.8, 0.9]) - # Set which layers to keep, and in what order - # The first layer index must be the base layer (index = 0) - # Since the base layer cannot be moved, - # the layers will be reordered as [img0, img2, img1] - cb.set_layer_sequence([0, 2, 1]) - # Remove the last layer (index = 2) cb.delete_layer(2) - # Add a new layer + # Add the removed image back as a new layer # The layer to be added cannot be one of the current layers - cb.add_layer(img1) + cb.add_layer(img2) + + # Layer objects can delete themselves from the color blending + red, green, blue = cb.layer_list() + blue.delete() + + # Deleting the base layer promotes the next layer to the spatial + # reference. The old base remains spatially matched but is removed + # from the color blending layers. + red.delete() + + # Append the old base as a new color blending layer if desired. + cb.add_layer(img0) # Set center cb.set_center(100, 100) @@ -362,9 +368,13 @@ Manipulate properties of the color blending object and the underlying layers: cb.close() .. note:: - The base layer (index = 0) cannot be deleted or moved. If you need to - change the layer order involving the base layer, close the current color - blending object and create a new one. + Layer indices are zero-based and refer to the current layer list. The + base layer (index = 0) can be deleted: if other layers remain, the next + layer becomes the new spatial reference; if it is the only layer, the + color blending object is closed. The old base remains spatially matched + to the new reference but is no longer part of the color blending until it + is appended as a new color blending layer with + :meth:`carta.color_blending.ColorBlending.add_layer`. Saving or displaying an image ----------------------------- diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 6ee607b..3dd47f3 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -138,6 +138,14 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") +def test_layer_delete(layer, mocker): + delete_layer = mocker.patch.object(layer.color_blending, "delete_layer") + + layer.delete() + + delete_layer.assert_called_once_with(layer.layer_id) + + def test_layer_image_view_order(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=7) layer_property("file_id", 42) @@ -313,17 +321,74 @@ def test_color_blending_add_layer(color_blending, cb_call_action, image): @pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) def test_color_blending_delete_layer( - color_blending, cb_call_action, idx, expected_param + color_blending, cb_call_action, idx, expected_param, mocker ): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=4, + ) color_blending.delete_layer(idx) cb_call_action.assert_called_with("deleteSelectedFrame", expected_param) -def test_color_blending_delete_layer_rejects_base_layer( - color_blending, cb_call_action +def test_color_blending_delete_base_layer_promotes_next_layer( + color_blending, cb_call_action, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + layers = [Layer(color_blending, 0), Layer(color_blending, 1)] + mocker.patch.object(color_blending, "layer_list", return_value=layers) + mocker.patch.object( + Layer, "file_id", new_callable=mocker.PropertyMock, return_value=42 + ) + image = mocker.patch("carta.color_blending.Image", autospec=True) + + color_blending.delete_layer(0) + + cb_call_action.assert_not_called() + image.assert_called_once_with(color_blending.session, 42) + image.return_value.make_spatial_reference.assert_called_once_with() + + +def test_color_blending_delete_only_base_layer_closes_color_blending( + color_blending, cb_call_action, mocker ): - with pytest.raises(ValueError, match="The base layer cannot be deleted."): - color_blending.delete_layer(0) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=1, + ) + mocker.patch.object( + color_blending, "layer_list", return_value=[Layer(color_blending, 0)] + ) + close = mocker.patch.object(color_blending, "close") + + color_blending.delete_layer(0) + + close.assert_called_once_with() + cb_call_action.assert_not_called() + + +@pytest.mark.parametrize("idx", [-1, 2]) +def test_color_blending_delete_layer_rejects_out_of_range( + color_blending, cb_call_action, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.delete_layer(idx) cb_call_action.assert_not_called() From 4168f2942ff71fa915a60bf66347641070956ac5 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Wed, 12 Aug 2026 22:27:22 +0800 Subject: [PATCH 79/95] Refactor set_alpha validation to use Evaluate validator with depth attribute instead of manual length checking --- carta/color_blending.py | 12 ++++-------- tests/test_color_blending.py | 23 +++++++++++++++++++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 206a24f..f982940 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -259,7 +259,9 @@ def depth(self): """The number of layers in the color blending.""" return self.get_value("frames.length") - @validate(IterableOf(Number(0, 1))) + @validate( + Evaluate(IterableOf, Number(0, 1), Attr("depth"), Attr("depth")) + ) def set_alpha(self, alpha_list): """Set the alpha value for the color blending layers. @@ -268,13 +270,7 @@ def set_alpha(self, alpha_list): alpha_list : {0} The alpha values. """ - layer_list = self.layer_list() - if len(alpha_list) != len(layer_list): - raise ValueError( - f"alpha_list length ({len(alpha_list)}) does not match " - f"the number of layers ({len(layer_list)})." - ) - for alpha, layer in zip(alpha_list, layer_list): + for alpha, layer in zip(alpha_list, self.layer_list()): layer.set_alpha(alpha) def layer_list(self): diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 3dd47f3..9a1d033 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -464,6 +464,12 @@ def test_color_blending_set_colormap_set(color_blending, cb_call_action): def test_color_blending_set_alpha_valid(color_blending, mocker): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) color_blending.set_alpha([0.2, 0.8]) @@ -472,7 +478,14 @@ def test_color_blending_set_alpha_valid(color_blending, mocker): @pytest.mark.parametrize("vals", [[-0.1, 0.5], [1.2], [0.1, 2.0, 0.3]]) -def test_color_blending_set_alpha_invalid(color_blending, vals): +def test_color_blending_set_alpha_invalid(color_blending, vals, mocker): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + with pytest.raises(CartaValidationFailed): color_blending.set_alpha(vals) @@ -481,9 +494,15 @@ def test_color_blending_set_alpha_invalid(color_blending, vals): def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) - with pytest.raises(ValueError, match="does not match"): + with pytest.raises(CartaValidationFailed): color_blending.set_alpha(vals) From 69aedb9c7d3db6069bca35267f2fe3b96709f1a6 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 09:44:39 +0800 Subject: [PATCH 80/95] Add colormap and alpha properties to Layer class and improve docstring formatting --- carta/color_blending.py | 42 ++++++++++++++++++++++++++++++------ tests/test_color_blending.py | 18 +++++++++++++++- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index f982940..8bc4b6a 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -48,8 +48,7 @@ def __init__(self, color_blending, layer_id): @classmethod def from_list(cls, color_blending, layer_ids): - """ - Create a list of Layer objects from a list of layer IDs. + """Create a list of Layer objects from a list of layer IDs. Parameters ---------- @@ -103,6 +102,8 @@ def __repr__(self): try: name = self.file_name + colormap = self.colormap + alpha = self.alpha except CartaScriptingException: return ( f"[Closed] {cls}(image_view_order={order}, " @@ -111,7 +112,8 @@ def __repr__(self): return ( f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " - f"layer_id={self.layer_id}, file_name={name!r})" + f"layer_id={self.layer_id}, file_name={name!r}, " + f"colormap={colormap!r}, alpha={alpha!r})" ) @property @@ -136,6 +138,28 @@ def file_id(self): """ return self.get_value("frameInfo.fileId") + @property + def colormap(self): + """The colormap used to render this layer. + + Returns + ------- + string + The colormap name. + """ + return self.get_value("renderConfig.colorMap") + + @property + def alpha(self): + """The alpha value of this layer in the color blending. + + Returns + ------- + number + The alpha value, between 0 and 1. + """ + return self.color_blending.get_value(f"alpha[{self.layer_id}]") + def delete(self): """Delete this layer from its parent color blending.""" self.color_blending.delete_layer(self.layer_id) @@ -256,7 +280,13 @@ def alpha(self): @property def depth(self): - """The number of layers in the color blending.""" + """The number of layers in the color blending. + + Returns + ------- + integer + The number of layers. + """ return self.get_value("frames.length") @validate( @@ -274,9 +304,7 @@ def set_alpha(self, alpha_list): layer.set_alpha(alpha) def layer_list(self): - """ - Returns a list of Layer objects, each representing a layer in - this color blending object. + """Return a list of Layer objects for this color blending. Returns ------- diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 9a1d033..2faaa8c 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -74,10 +74,12 @@ def test_layer_repr_healthy(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=2) layer_property("file_id", 42) layer_property("file_name", "layer1.fits") + layer_property("colormap", "viridis") + layer_property("alpha", 0.5) r = repr(Layer(color_blending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " - "file_name='layer1.fits')" + "file_name='layer1.fits', colormap='viridis', alpha=0.5)" ) find.assert_called_once_with(ImageType.FRAME, 42) @@ -138,6 +140,20 @@ def test_layer_file_id_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileId") +def test_layer_colormap_property(layer, layer_get_value): + layer_get_value.return_value = "viridis" + + assert layer.colormap == "viridis" + layer_get_value.assert_called_once_with("renderConfig.colorMap") + + +def test_layer_alpha_property(layer, cb_get_value): + cb_get_value.return_value = 0.5 + + assert layer.alpha == 0.5 + cb_get_value.assert_called_once_with("alpha[1]") + + def test_layer_delete(layer, mocker): delete_layer = mocker.patch.object(layer.color_blending, "delete_layer") From 638b032ae488fe991a50daf1987b356b4eabd3c6 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 09:59:12 +0800 Subject: [PATCH 81/95] Add Layer.set_image method and allow ColorBlending.set_layer_image to handle base layer by making it the spatial reference --- carta/color_blending.py | 22 +++++++++++++++++++--- tests/test_color_blending.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 8bc4b6a..b02c194 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -164,6 +164,17 @@ def delete(self): """Delete this layer from its parent color blending.""" self.color_blending.delete_layer(self.layer_id) + @validate(InstanceOf(Image)) + def set_image(self, image): + """Set the image for this layer. + + Parameters + ---------- + image : {0} + The image to set. + """ + self.color_blending.set_layer_image(self.layer_id, image) + @validate(Number(0, 1)) def set_alpha(self, alpha): """Set the alpha value for the layer in the color blending. @@ -344,18 +355,23 @@ def delete_layer(self, layer_index): return self.call_action("deleteSelectedFrame", layer_index - 1) - @validate(Number(1, None), InstanceOf(Image)) + @validate(Number(0, None), InstanceOf(Image)) def set_layer_image(self, layer_index, image): """Set the image for a layer at a specified index in the color blending. Parameters ---------- layer_index : {0} - The layer index. The base layer (layer_index = 0) cannot - be set. + The layer index. If the base layer (layer_index = 0) is selected, + ``image`` becomes the new spatial reference. Otherwise, the + specified secondary layer is replaced. image : {1} The image to set. """ + if layer_index == 0: + image.set_spatial_matching(True) + image.make_spatial_reference() + return self.call_action("setSelectedFrame", layer_index - 1, image._frame) # NAVIGATION diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 2faaa8c..b7bcd89 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -162,6 +162,27 @@ def test_layer_delete(layer, mocker): delete_layer.assert_called_once_with(layer.layer_id) +def test_layer_set_image(layer, image, mocker): + set_layer_image = mocker.patch.object( + layer.color_blending, "set_layer_image" + ) + + layer.set_image(image) + + set_layer_image.assert_called_once_with(layer.layer_id, image) + + +def test_layer_set_image_rejects_invalid_image(layer, mocker): + set_layer_image = mocker.patch.object( + layer.color_blending, "set_layer_image" + ) + + with pytest.raises(CartaValidationFailed): + layer.set_image(object()) + + set_layer_image.assert_not_called() + + def test_layer_image_view_order(session, color_blending, layer_property, mocker): find = mocker.patch.object(session, "_find_image_view_order", return_value=7) layer_property("file_id", 42) @@ -419,6 +440,17 @@ def test_color_blending_set_layer_image( ) +def test_color_blending_set_base_layer_image(color_blending, cb_call_action, image, mocker): + set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") + make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") + + color_blending.set_layer_image(0, image) + + set_spatial_matching.assert_called_once_with(True) + make_spatial_reference.assert_called_once_with() + cb_call_action.assert_not_called() + + def test_color_blending_set_center(color_blending, mocker): base_frame = mocker.create_autospec(Image, instance=True) mocker.patch( From 26f49837a55568714b7c42486e7c96ddc21231ab Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 10:27:29 +0800 Subject: [PATCH 82/95] Add Session.images and Session.color_blendings methods to retrieve image lists with optional ID filtering --- carta/session.py | 66 +++++++++++++++++++++++++++++++++++++++ carta/wcs_overlay.py | 11 ++----- tests/test_session.py | 63 +++++++++++++++++++++++++++++++++++++ tests/test_wcs_overlay.py | 3 +- 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/carta/session.py b/carta/session.py index 2706047..b7eb8ab 100644 --- a/carta/session.py +++ b/carta/session.py @@ -588,6 +588,72 @@ def image_list(self): ) return result + def _image_list(self, path, image_class, return_path): + count = self.get_value(f"{path}.length") + return [ + image_class( + self, + self.get_value( + f"{path}[{index}]", + return_path=return_path, + ), + ) + for index in range(count) + ] + + @validate(NoneOr(IterableOf(Number.ID))) + def images(self, file_ids=None): + """Return frame-backed images from the session. + + When no IDs are supplied, all open frame-backed images are returned. + When IDs are supplied, they are validated against the session's frame + map. + + Parameters + ---------- + file_ids : {0} + The file IDs of the images to return. By default, all open + frame-backed images are returned. + + Returns + ------- + list of :obj:`carta.image.Image` + The requested frame-backed images. + """ + if file_ids is None: + return self._image_list("frames", Image, "frameInfo.fileId") + return [self.image_by_id(file_id=file_id) for file_id in file_ids] + + @validate(NoneOr(IterableOf(Number.ID))) + def color_blendings(self, color_blending_ids=None): + """Return color blending images from the session. + + When no IDs are supplied, all open color blending images are + returned. When IDs are supplied, they are validated against the + session's color blending map. + + Parameters + ---------- + color_blending_ids : {0} + The IDs of the color blending images to return. By default, all + open color blending images are returned. + + Returns + ------- + list of :obj:`carta.color_blending.ColorBlending` + The requested color blending images. + """ + if color_blending_ids is None: + return self._image_list( + "imageViewConfigStore.colorBlendingImages", + ColorBlending, + "id", + ) + return [ + self.image_by_id(color_blending_id=color_blending_id) + for color_blending_id in color_blending_ids + ] + def _find_image_view_order(self, image_type, stable_id): """Return the image-view order of an item identified by a stable id. diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index a46a440..f59396c 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -435,21 +435,14 @@ class ImageWCSConnector: ANY_IDS = NoneOr(IterableOf(Number.ID)) - def _images(self, image_ids=None): - """Internal helper function for fetching image objects.""" - from .image import Image - if image_ids is None: - return [img for img in self.session.image_list() if isinstance(img, Image)] - return [self.session.image_by_id(file_id=image_id) for image_id in image_ids] - def _get_image_wcs_properties(self, image_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" - images = self._images(image_ids) + images = self.session.images(image_ids) return tuple(attrgetter(property_path)(image.wcs) for image in images) def _call_image_wcs_functions(self, image_ids, function_path, *function_args): """Internal helper function for executing wcs functions on multiple images.""" - images = self._images(image_ids) + images = self.session.images(image_ids) for image in images: attrgetter(function_path)(image.wcs)(*function_args) diff --git a/tests/test_session.py b/tests/test_session.py index 40f65cd..c50cb8e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -162,6 +162,69 @@ def test_image_list_empty(session, get_value): ) +def test_images_uses_frame_list(session, get_value): + get_value.side_effect = [2, 10, 20] + + images = session.images() + + assert [image.file_id for image in images] == [10, 20] + assert get_value.call_args_list == [ + call("frames.length"), + call("frames[0]", return_path="frameInfo.fileId"), + call("frames[1]", return_path="frameInfo.fileId"), + ] + + +def test_images_uses_frame_map_for_explicit_ids(session, mocker): + image_by_id = mocker.patch.object(session, "image_by_id") + image_by_id.side_effect = [object(), object()] + + images = session.images(file_ids=[10, 20]) + + assert len(images) == 2 + assert image_by_id.call_args_list == [ + call(file_id=10), + call(file_id=20), + ] + + +def test_color_blendings_uses_color_blending_list(session, get_value): + get_value.side_effect = [2, 3, 7] + + color_blendings = session.color_blendings() + + assert [ + color_blending.color_blending_id + for color_blending in color_blendings + ] == [3, 7] + assert get_value.call_args_list == [ + call("imageViewConfigStore.colorBlendingImages.length"), + call( + "imageViewConfigStore.colorBlendingImages[0]", + return_path="id", + ), + call( + "imageViewConfigStore.colorBlendingImages[1]", + return_path="id", + ), + ] + + +def test_color_blendings_uses_color_blending_map_for_explicit_ids( + session, mocker +): + image_by_id = mocker.patch.object(session, "image_by_id") + image_by_id.side_effect = [object(), object()] + + color_blendings = session.color_blendings([3, 7]) + + assert len(color_blendings) == 2 + assert image_by_id.call_args_list == [ + call(color_blending_id=3), + call(color_blending_id=7), + ] + + def test_find_image_view_order_single_round_trip(session, call_action): call_action.side_effect = [2, 1] diff --git a/tests/test_wcs_overlay.py b/tests/test_wcs_overlay.py index e462b92..1f3b4f4 100644 --- a/tests/test_wcs_overlay.py +++ b/tests/test_wcs_overlay.py @@ -1,7 +1,6 @@ import pytest from carta.util import CartaValidationFailed -from carta.wcs_overlay import ImageWCSConnector from carta.constants import NumberFormat as NF, Overlay as O, CoordinateSystem as CS, PaletteColor as PC, FontFamily as FF, FontStyle as FS, LabelType as LT, ColorbarPosition as CP, BeamType as BT @@ -66,7 +65,7 @@ def image_beam_property(mock_property): @pytest.fixture def mock_images(image, mocker): - return mocker.patch.object(ImageWCSConnector, "_images", return_value=[image]) + return mocker.patch.object(image.session, "images", return_value=[image]) # TESTS From 9f44decca24c3110bd8605c512a19f49f431601e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 10:36:55 +0800 Subject: [PATCH 83/95] Update quickstart documentation --- docs/source/quickstart.rst | 56 +++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 73d2d18..f852b92 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -197,17 +197,18 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba The frontend image list panel. Each row corresponds to an item returned by :obj:`carta.session.Session.image_list`, and its position in the list is the item's ``image_view_order``. -.. code-block:: python +Use :meth:`carta.session.Session.images` and +:meth:`carta.session.Session.color_blendings` to retrieve the two concrete +image-view types directly, optionally filtering by their stable IDs. - from carta.image import Image - from carta.color_blending import ColorBlending +.. code-block:: python # All open image-view items, in display order items = session.image_list() - # Filter by type if needed - images = [i for i in items if isinstance(i, Image)] - color_blendings = [i for i in items if isinstance(i, ColorBlending)] + # Get frame-backed images or color blending images directly + images = session.images() + color_blendings = session.color_blendings() # Every image-view item exposes its current image-view order print(img0.image_view_order) @@ -215,7 +216,13 @@ The session's image list is heterogeneous: it may contain both ordinary frame-ba # Retrieve a specific item by image view order img = session.image_by_id(image_view_order=0) cb = session.image_by_id(image_view_order=1) - + + # Filter by stable IDs when needed + images = session.images(file_ids=[img0.file_id, img1.file_id]) + color_blendings = session.color_blendings( + color_blending_ids=[cb.color_blending_id] + ) + Changing image properties ------------------------- @@ -224,7 +231,7 @@ Properties specific to individual images can be accessed through image objects: .. code-block:: python import numpy as np - from carta.constants import Colormap, Scaling, Polarization + from carta.constants import Colormap, ColormapSet, Scaling, Polarization # change the channel and polarization img.set_channel(10) @@ -308,12 +315,15 @@ Manipulate properties of the color blending object and the underlying layers: .. code-block:: python # Get layer objects - red, green, blue = cb.layer_list() + layer1, layer2, layer3 = cb.layer_list() - # Set colormap for individual layers - red.set_colormap(Colormap.REDS) - green.set_colormap(Colormap.GREENS) - blue.set_colormap(Colormap.BLUES) + # Set colormap for the images in individual layers + layer1.set_colormap(Colormap.REDS) + layer2.set_colormap(Colormap.GREENS) + layer3.set_colormap(Colormap.BLUES) + + # Inspect the colormap and alpha of an individual layer + print(layer1.colormap, layer1.alpha) # Or apply an existing colormap set cb.set_colormap_set(ColormapSet.RGB) @@ -322,13 +332,17 @@ Manipulate properties of the color blending object and the underlying layers: print(cb.alpha) # Set alpha for individual layers - red.set_alpha(0.7) - green.set_alpha(0.8) - blue.set_alpha(0.9) + layer1.set_alpha(0.7) + layer2.set_alpha(0.8) + layer3.set_alpha(0.9) # Or set alpha for all layers at once cb.set_alpha([0.7, 0.8, 0.9]) + # Replace the image in a layer + # For layer1, this also makes the new image the spatial reference. + # layer1.set_image(new_image) + # Remove the last layer (index = 2) cb.delete_layer(2) @@ -337,13 +351,13 @@ Manipulate properties of the color blending object and the underlying layers: cb.add_layer(img2) # Layer objects can delete themselves from the color blending - red, green, blue = cb.layer_list() - blue.delete() + layer1, layer2, layer3 = cb.layer_list() + layer3.delete() # Deleting the base layer promotes the next layer to the spatial # reference. The old base remains spatially matched but is removed # from the color blending layers. - red.delete() + layer1.delete() # Append the old base as a new color blending layer if desired. cb.add_layer(img0) @@ -376,6 +390,10 @@ Manipulate properties of the color blending object and the underlying layers: is appended as a new color blending layer with :meth:`carta.color_blending.ColorBlending.add_layer`. + An image can be replaced through :meth:`carta.color_blending.Layer.set_image`. + Replacing the base layer first enables spatial matching for the new image + and then makes it the spatial reference. + Saving or displaying an image ----------------------------- From 14de716c5d42708c846a409343663a5b0e7af1a2 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 13:34:54 +0800 Subject: [PATCH 84/95] Rename image_ids parameter to file_ids in wcs_overlay for consistency with Session.images method --- carta/wcs_overlay.py | 104 +++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index f59396c..6041f05 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -435,14 +435,14 @@ class ImageWCSConnector: ANY_IDS = NoneOr(IterableOf(Number.ID)) - def _get_image_wcs_properties(self, image_ids, property_path): + def _get_image_wcs_properties(self, file_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" - images = self.session.images(image_ids) + images = self.session.images(file_ids) return tuple(attrgetter(property_path)(image.wcs) for image in images) - def _call_image_wcs_functions(self, image_ids, function_path, *function_args): + def _call_image_wcs_functions(self, file_ids, function_path, *function_args): """Internal helper function for executing wcs functions on multiple images.""" - images = self.session.images(image_ids) + images = self.session.images(file_ids) for image in images: attrgetter(function_path)(image.wcs)(*function_args) @@ -535,12 +535,12 @@ class Title(HasCustomColor, HasCustomText, HasFont, HasVisibility, ImageWCSConne COMPONENT = Overlay.TITLE @validate(ImageWCSConnector.ANY_IDS) - def text(self, image_ids=None): + def text(self, file_ids=None): """The custom title text for the specified images. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. Returns @@ -548,10 +548,10 @@ def text(self, image_ids=None): tuple of string The title text of the specified images. """ - return self._get_image_wcs_properties(image_ids, "title.text") + return self._get_image_wcs_properties(file_ids, "title.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, title_text, image_ids=None): + def set_text(self, title_text, file_ids=None): """Set the custom title text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -560,10 +560,10 @@ def set_text(self, title_text, image_ids=None): ---------- title_text : {0} The custom title text for the specified images. - image_ids : {1} + file_ids : {1} The images to configure. """ - self._call_image_wcs_functions(image_ids, "title.set_text", title_text) + self._call_image_wcs_functions(file_ids, "title.set_text", title_text) class Grid(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): @@ -1047,12 +1047,12 @@ class ColorbarLabel(HasVisibility, HasCustomColor, HasCustomText, HasFont, HasRo PREFIX = "label" @validate(ImageWCSConnector.ANY_IDS) - def text(self, image_ids=None): + def text(self, file_ids=None): """The custom colorbar label text for the specified images. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. Returns @@ -1060,10 +1060,10 @@ def text(self, image_ids=None): tuple of string The colorbar label text of the specified images. """ - return self._get_image_wcs_properties(image_ids, "colorbar.label.text") + return self._get_image_wcs_properties(file_ids, "colorbar.label.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, label_text, image_ids=None): + def set_text(self, label_text, file_ids=None): """Set the custom colorbar label text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -1072,11 +1072,11 @@ def set_text(self, label_text, image_ids=None): ---------- label_text : {0} The custom colorbar label text for the specified images. - image_ids : {1} + file_ids : {1} The images to configure. """ - self._call_image_wcs_functions(image_ids, "colorbar.label.set_text", label_text) + self._call_image_wcs_functions(file_ids, "colorbar.label.set_text", label_text) class ColorbarGradient(HasVisibility, ColorbarComponent): @@ -1200,12 +1200,12 @@ class Beam(ImageWCSConnector, OverlayComponent): COMPONENT = Overlay.BEAM @validate(ImageWCSConnector.ANY_IDS) - def position(self, image_ids=None): + def position(self, file_ids=None): """The beam position. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1213,15 +1213,15 @@ def position(self, image_ids=None): tuple of (number, number) tuples The X and Y beam positions of the specified images, in pixels. """ - return self._get_image_wcs_properties(image_ids, "beam.position") + return self._get_image_wcs_properties(file_ids, "beam.position") @validate(ImageWCSConnector.ANY_IDS) - def type(self, image_ids=None): + def type(self, file_ids=None): """The beam type. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1229,15 +1229,15 @@ def type(self, image_ids=None): tuple of members of :obj:`carta.constants.BeamType` The beam types of the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.type") + return self._get_image_wcs_properties(file_ids, "beam.type") @validate(ImageWCSConnector.ANY_IDS) - def color(self, image_ids=None): + def color(self, file_ids=None): """The color of this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1245,15 +1245,15 @@ def color(self, image_ids=None): tuple of members of :obj:`carta.constants.color.PaletteColor` The colors of the beam in the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.color") + return self._get_image_wcs_properties(file_ids, "beam.color") @validate(ImageWCSConnector.ANY_IDS) - def visible(self, image_ids=None): + def visible(self, file_ids=None): """The visibility of this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1261,15 +1261,15 @@ def visible(self, image_ids=None): tuple of boolean Whether the beam is visible in the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.visible") + return self._get_image_wcs_properties(file_ids, "beam.visible") @validate(ImageWCSConnector.ANY_IDS) - def width(self, image_ids=None): + def width(self, file_ids=None): """The width of this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1277,10 +1277,10 @@ def width(self, image_ids=None): tuple of boolean The width of the beam in the specified images. """ - return self._get_image_wcs_properties(image_ids, "beam.width") + return self._get_image_wcs_properties(file_ids, "beam.width") @validate(*all_optional(Number(), Number(), ImageWCSConnector.ANY_IDS)) - def set_position(self, position_x=None, position_y=None, image_ids=None): + def set_position(self, position_x=None, position_y=None, file_ids=None): """Set the beam position. Parameters @@ -1289,84 +1289,84 @@ def set_position(self, position_x=None, position_y=None, image_ids=None): The X position, in pixels. position_y : {1} The Y position, in pixels. - image_ids : {2} + file_ids : {2} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_position", position_x, position_y) + self._call_image_wcs_functions(file_ids, "beam.set_position", position_x, position_y) @validate(Constant(BeamType), ImageWCSConnector.ANY_IDS) - def set_type(self, beam_type, image_ids=None): + def set_type(self, beam_type, file_ids=None): """Set the beam type. Parameters ---------- beam_type : {0} The beam type. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_type", beam_type) + self._call_image_wcs_functions(file_ids, "beam.set_type", beam_type) @validate(Constant(PaletteColor), ImageWCSConnector.ANY_IDS) - def set_color(self, color, image_ids=None): + def set_color(self, color, file_ids=None): """Set the color of this component. Parameters ---------- color : {0} The color. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_color", color) + self._call_image_wcs_functions(file_ids, "beam.set_color", color) @validate(Boolean(), ImageWCSConnector.ANY_IDS) - def set_visible(self, state, image_ids=None): + def set_visible(self, state, file_ids=None): """Set the visibility of this component. Parameters ---------- visible : {0} Whether this component should be visible. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_visible", state) + self._call_image_wcs_functions(file_ids, "beam.set_visible", state) @validate(ImageWCSConnector.ANY_IDS) - def show(self, image_ids=None): + def show(self, file_ids=None): """Show this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(True, image_ids) + self.set_visible(True, file_ids) @validate(ImageWCSConnector.ANY_IDS) - def hide(self, image_ids=None): + def hide(self, file_ids=None): """Hide this component. Parameters ---------- - image_ids : {0} + file_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(False, image_ids) + self.set_visible(False, file_ids) @validate(Number.POSITIVE, ImageWCSConnector.ANY_IDS) - def set_width(self, width, image_ids=None): + def set_width(self, width, file_ids=None): """Set the width of this component. Parameters ---------- width : {0} The width. - image_ids : {1} + file_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(image_ids, "beam.set_width", width) + self._call_image_wcs_functions(file_ids, "beam.set_width", width) class ImageWCSOverlay(BasePathMixin): From e6d93cef0663f01e6f15fbee29fedecaac4f62b2 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 16:08:51 +0800 Subject: [PATCH 85/95] Add inverted property to Layer class and include it in repr output --- carta/color_blending.py | 15 ++++++++++++++- docs/source/quickstart.rst | 4 ++-- tests/test_color_blending.py | 11 ++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index b02c194..720e234 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -103,6 +103,7 @@ def __repr__(self): try: name = self.file_name colormap = self.colormap + inverted = self.inverted alpha = self.alpha except CartaScriptingException: return ( @@ -113,7 +114,8 @@ def __repr__(self): return ( f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " f"layer_id={self.layer_id}, file_name={name!r}, " - f"colormap={colormap!r}, alpha={alpha!r})" + f"colormap={colormap!r}, inverted={inverted!r}, " + f"alpha={alpha!r})" ) @property @@ -149,6 +151,17 @@ def colormap(self): """ return self.get_value("renderConfig.colorMap") + @property + def inverted(self): + """Whether the layer's colormap is inverted. + + Returns + ------- + boolean + Whether the colormap is inverted. + """ + return self.get_value("renderConfig.isInverted") + @property def alpha(self): """The alpha value of this layer in the color blending. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index f852b92..0e03383 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -322,8 +322,8 @@ Manipulate properties of the color blending object and the underlying layers: layer2.set_colormap(Colormap.GREENS) layer3.set_colormap(Colormap.BLUES) - # Inspect the colormap and alpha of an individual layer - print(layer1.colormap, layer1.alpha) + # Inspect the colormap, inversion, and alpha of an individual layer + print(layer1.colormap, layer1.inverted, layer1.alpha) # Or apply an existing colormap set cb.set_colormap_set(ColormapSet.RGB) diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index b7bcd89..dc84a33 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -75,11 +75,13 @@ def test_layer_repr_healthy(session, color_blending, layer_property, mocker): layer_property("file_id", 42) layer_property("file_name", "layer1.fits") layer_property("colormap", "viridis") + layer_property("inverted", False) layer_property("alpha", 0.5) r = repr(Layer(color_blending, 3)) assert r == ( "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " - "file_name='layer1.fits', colormap='viridis', alpha=0.5)" + "file_name='layer1.fits', colormap='viridis', inverted=False, " + "alpha=0.5)" ) find.assert_called_once_with(ImageType.FRAME, 42) @@ -147,6 +149,13 @@ def test_layer_colormap_property(layer, layer_get_value): layer_get_value.assert_called_once_with("renderConfig.colorMap") +def test_layer_inverted_property(layer, layer_get_value): + layer_get_value.return_value = True + + assert layer.inverted is True + layer_get_value.assert_called_once_with("renderConfig.isInverted") + + def test_layer_alpha_property(layer, cb_get_value): cb_get_value.return_value = 0.5 From 2f49b2a0c939e4470f877140c6c5a80b59c2fc56 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 16:29:06 +0800 Subject: [PATCH 86/95] Update validate decorator to support keyword-only parameters by including spec.kwonlyargs in parameter name extraction --- carta/validation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/carta/validation.py b/carta/validation.py index 9e68d15..c99bc30 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -910,7 +910,7 @@ def description(self): def validate(*vargs): """The function which returns the decorator used to validate method parameters. - It is assumed that the function to be decorated is an object method and the first parameter is ``self``; this parameter is therefore ignored by the decorator. The remaining positional parameters are validated in order using the provided descriptors. The descriptors are also combined pairwise with the parameter names in the signature of the original function to create a dictionary for validating keyword parameters. + It is assumed that the function to be decorated is an object method and the first parameter is ``self``; this parameter is therefore ignored by the decorator. The remaining positional and keyword-only parameters are validated in order using the provided descriptors. The descriptors are also combined pairwise with the parameter names in the signature of the original function to create a dictionary for validating keyword parameters. Functions with ``*args`` or ``**kwargs`` are not currently supported: use iterables and explicit keyword parameters instead. @@ -932,7 +932,9 @@ def validate(*vargs): """ def decorator(func): - kwvargs = {k: v for (k, v) in zip(inspect.getfullargspec(func).args[1:], vargs)} + spec = inspect.getfullargspec(func) + parameter_names = spec.args[1:] + spec.kwonlyargs + kwvargs = {k: v for (k, v) in zip(parameter_names, vargs)} STRIP_OBJ = re.compile(":obj:`(.*)`") STRIP_CODE = re.compile("``(.*)``") PRESERVE = re.compile("(:obj:`.+?`|``.+?``)") From 8ea65e8b12db2212ebacae93da1c5757b6e4f73e Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 16:29:53 +0800 Subject: [PATCH 87/95] Add validation decorators to ColorBlending.add_layer, ColorBlending.set_layer_image, and Session.image_by_id methods with corresponding test coverage --- carta/color_blending.py | 6 ++++- carta/session.py | 10 ++++---- tests/test_color_blending.py | 44 ++++++++++++++++++++++++++++++++++-- tests/test_session.py | 15 ++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 720e234..a0b9c56 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -337,6 +337,7 @@ def layer_list(self): """ return Layer.from_list(self, list(range(self.depth))) + @validate(InstanceOf(Image)) def add_layer(self, image): """Add a new layer to the color blending. @@ -368,7 +369,10 @@ def delete_layer(self, layer_index): return self.call_action("deleteSelectedFrame", layer_index - 1) - @validate(Number(0, None), InstanceOf(Image)) + @validate( + Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1), + InstanceOf(Image), + ) def set_layer_image(self, layer_index, image): """Set the image for a layer at a specified index in the color blending. diff --git a/carta/session.py b/carta/session.py index b7eb8ab..cc66b3a 100644 --- a/carta/session.py +++ b/carta/session.py @@ -688,7 +688,10 @@ def _find_image_view_order(self, image_type, stable_id): ) return image_view_order - def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id=None): + @validate(NoneOr(Number.ID), NoneOr(Number.ID), NoneOr(Number.ID)) + def image_by_id( + self, *, image_view_order=None, file_id=None, color_blending_id=None + ): """Return the image-view item identified by exactly one of the supported identifiers. Parameters @@ -740,11 +743,6 @@ def image_by_id(self, *, image_view_order=None, file_id=None, color_blending_id= ) if image_view_order is not None: - if image_view_order < 0: - raise IndexError( - f"image_view_order {image_view_order} is out of range " - "for the image list." - ) try: entry = self.get_value( "imageViewConfigStore.imageListSummary" diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index dc84a33..8897b48 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -365,6 +365,15 @@ def test_color_blending_add_layer(color_blending, cb_call_action, image): cb_call_action.assert_called_with("addSelectedFrame", image._frame) +def test_color_blending_add_layer_rejects_invalid_image( + color_blending, cb_call_action +): + with pytest.raises(CartaValidationFailed): + color_blending.add_layer(object()) + + cb_call_action.assert_not_called() + + @pytest.mark.parametrize("idx,expected_param", [(1, 0), (3, 2)]) def test_color_blending_delete_layer( color_blending, cb_call_action, idx, expected_param, mocker @@ -441,15 +450,46 @@ def test_color_blending_delete_layer_rejects_out_of_range( @pytest.mark.parametrize("idx,expected_param", [(1, 0), (5, 4)]) def test_color_blending_set_layer_image( - color_blending, cb_call_action, image, idx, expected_param + color_blending, cb_call_action, image, idx, expected_param, mocker ): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=6, + ) color_blending.set_layer_image(idx, image) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame ) -def test_color_blending_set_base_layer_image(color_blending, cb_call_action, image, mocker): +@pytest.mark.parametrize("idx", [-1, 2]) +def test_color_blending_set_layer_image_rejects_out_of_range( + color_blending, cb_call_action, image, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=2, + ) + + with pytest.raises(CartaValidationFailed): + color_blending.set_layer_image(idx, image) + + cb_call_action.assert_not_called() + + +def test_color_blending_set_base_layer_image( + color_blending, cb_call_action, image, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=1, + ) set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") diff --git a/tests/test_session.py b/tests/test_session.py index c50cb8e..58aacb1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -274,6 +274,21 @@ def test_image_by_id_rejects_positional(session): session.image_by_id(0) +@pytest.mark.parametrize("keyword", [ + "image_view_order", + "file_id", + "color_blending_id", +]) +@pytest.mark.parametrize("value", [-1, 1.5, "1"]) +def test_image_by_id_rejects_invalid_identifier( + session, get_value, keyword, value +): + with pytest.raises(CartaValidationFailed): + session.image_by_id(**{keyword: value}) + + get_value.assert_not_called() + + @pytest.mark.parametrize( "entry,expected_type,expected_id", [ From 272366c608e25512c91be8f104e0e24dc90fea78 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 19:31:03 +0800 Subject: [PATCH 88/95] Simplify Session.image_by_id to directly instantiate Image and ColorBlending classes instead of using ImageBase.image_class factory method --- carta/session.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/carta/session.py b/carta/session.py index cc66b3a..049179a 100644 --- a/carta/session.py +++ b/carta/session.py @@ -765,9 +765,7 @@ def image_by_id( raise RuntimeError( f"No frame-backed image with file_id={file_id} is open." ) from e - return ImageBase.image_class(ImageType.FRAME)( - self, resolved_file_id - ) + return Image(self, resolved_file_id) # color_blending_id is not None try: @@ -781,9 +779,7 @@ def image_by_id( f"No color blending with color_blending_id={color_blending_id} " "is open." ) from e - return ImageBase.image_class(ImageType.COLOR_BLENDING)( - self, resolved_color_blending_id - ) + return ColorBlending(self, resolved_color_blending_id) def active_image(self): """Return the currently active image-view item. From 03be496659af8c1824bd3fdd3c13a7abe3d977c4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 21:24:12 +0800 Subject: [PATCH 89/95] Rename image_view_order to view_index and file_id to image_id throughout codebase, refactor ImageBase to View class, and update related documentation and method names for consistency --- carta/color_blending.py | 50 +++++----- carta/constants.py | 2 +- carta/image.py | 38 ++++---- carta/image_base.py | 87 ----------------- carta/session.py | 169 +++++++++++++++++---------------- carta/view.py | 75 +++++++++++++++ carta/wcs_overlay.py | 104 ++++++++++----------- docs/source/carta.rst | 6 +- docs/source/quickstart.rst | 41 ++++---- tests/test_color_blending.py | 76 +++++++-------- tests/test_image.py | 84 +++++++++-------- tests/test_session.py | 176 ++++++++++++++++++++--------------- 12 files changed, 467 insertions(+), 441 deletions(-) delete mode 100644 carta/image_base.py create mode 100644 carta/view.py diff --git a/carta/color_blending.py b/carta/color_blending.py index a0b9c56..ace85cc 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -2,7 +2,7 @@ from .constants import Colormap, ColormapSet, ImageType, SpatialAxis from .image import Image -from .image_base import ImageBase +from .view import View from .util import BasePathMixin, CartaScriptingException, Macro from .validation import ( Boolean, @@ -65,27 +65,25 @@ def from_list(cls, color_blending, layer_ids): return [cls(color_blending, layer_id) for layer_id in layer_ids] @property - def image_view_order(self): - """The image-view order of this layer's underlying frame. + def view_index(self): + """The view index of this layer's underlying image. - This is the position of the underlying frame in the session's image - list. A layer does not occupy its own position in the image list; - its parent color blending does (see - :obj:`carta.color_blending.ColorBlending.image_view_order`). + This is the position of the underlying image in the session's views. + A layer does not occupy its own position in the views; its parent + color blending does (see + :obj:`carta.color_blending.ColorBlending.view_index`). Returns ------- integer - The image-view order of the underlying frame. + The view index of the underlying image. Raises ------ RuntimeError - If no matching frame entry exists in the image list. + If no matching image entry exists in the views. """ - return self.session._find_image_view_order( - ImageType.FRAME, self.file_id - ) + return self.session._find_view_index(ImageType.FRAME, self.image_id) def __repr__(self): """A human-readable representation of this layer.""" @@ -93,10 +91,10 @@ def __repr__(self): cb_id = self.color_blending.color_blending_id try: - order = self.image_view_order + index = self.view_index except (CartaScriptingException, RuntimeError): return ( - f"[Closed] {cls}(image_view_order=None, " + f"[Closed] {cls}(view_index=None, " f"color_blending_id={cb_id}, layer_id={self.layer_id})" ) @@ -107,12 +105,12 @@ def __repr__(self): alpha = self.alpha except CartaScriptingException: return ( - f"[Closed] {cls}(image_view_order={order}, " + f"[Closed] {cls}(view_index={index}, " f"color_blending_id={cb_id}, layer_id={self.layer_id})" ) return ( - f"{cls}(image_view_order={order}, color_blending_id={cb_id}, " + f"{cls}(view_index={index}, color_blending_id={cb_id}, " f"layer_id={self.layer_id}, file_name={name!r}, " f"colormap={colormap!r}, inverted={inverted!r}, " f"alpha={alpha!r})" @@ -130,13 +128,13 @@ def file_name(self): return self.get_value("frameInfo.fileInfo.name") @property - def file_id(self): - """The frontend file id of the layer's underlying image. + def image_id(self): + """The frontend image id of the layer's underlying image. Returns ------- integer - The file id. + The image id. """ return self.get_value("frameInfo.fileId") @@ -214,7 +212,7 @@ def set_colormap(self, colormap, invert=False): self.call_action("renderConfig.setInverted", invert) -class ColorBlending(ImageBase, BasePathMixin): +class ColorBlending(View, BasePathMixin): """This object represents a color blending image in a session. Parameters @@ -232,7 +230,7 @@ class ColorBlending(ImageBase, BasePathMixin): The id of the backing ``ColorBlendingStore`` on the frontend. """ - IMAGE_TYPE = ImageType.COLOR_BLENDING + VIEW_TYPE = ImageType.COLOR_BLENDING def __init__(self, session, color_blending_id): super().__init__(session) @@ -251,10 +249,10 @@ def __repr__(self): cls = type(self).__name__ try: - order = self.image_view_order + index = self.view_index except (CartaScriptingException, RuntimeError): return ( - f"[Closed] {cls}(image_view_order=None, " + f"[Closed] {cls}(view_index=None, " f"color_blending_id={self.color_blending_id})" ) @@ -262,12 +260,12 @@ def __repr__(self): name = self.file_name except CartaScriptingException: return ( - f"[Closed] {cls}(image_view_order={order}, " + f"[Closed] {cls}(view_index={index}, " f"color_blending_id={self.color_blending_id})" ) return ( - f"{cls}(image_view_order={order}, " + f"{cls}(view_index={index}, " f"color_blending_id={self.color_blending_id}, " f"file_name={name!r})" ) @@ -365,7 +363,7 @@ def delete_layer(self, layer_index): self.close() return - Image(self.session, layers[1].file_id).make_spatial_reference() + Image(self.session, layers[1].image_id).make_spatial_reference() return self.call_action("deleteSelectedFrame", layer_index - 1) diff --git a/carta/constants.py b/carta/constants.py index 0cdf183..87dc673 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -31,7 +31,7 @@ class ColormapSet(StrEnum): class ImageType(IntEnum): - """Image view item types, corresponding to the frontend ImageType enum.""" + """View item types, corresponding to the frontend ImageType enum.""" FRAME = 0 COLOR_BLENDING = 1 PV_PREVIEW = 2 diff --git a/carta/image.py b/carta/image.py index d635911..bd2c2fa 100644 --- a/carta/image.py +++ b/carta/image.py @@ -1,11 +1,11 @@ -"""This module contains the image classes representing image-view items open in the session. +"""This module contains the image classes representing images open in the session. Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ from .constants import ImageType, Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .image_base import ImageBase +from .view import View from .util import Macro, cached, BasePathMixin, CartaScriptingException, Point as Pt from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point @@ -17,8 +17,8 @@ from .region import RegionSet -class Image(ImageBase, BasePathMixin): - """This object corresponds to a frame-backed image open in a CARTA frontend session. +class Image(View, BasePathMixin): + """This object corresponds to an image open in a CARTA frontend session. This class should not be instantiated directly. Instead, use the session object's methods for opening new images or retrieving existing images. @@ -26,15 +26,15 @@ class Image(ImageBase, BasePathMixin): ---------- session : :obj:`carta.session.Session` The session object associated with this image. - file_id : integer - The frontend file ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. + image_id : integer + The frontend image ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. - file_id : integer - The frontend file ID identifying this image within the session. + image_id : integer + The frontend image ID identifying this image within the session. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` @@ -47,13 +47,13 @@ class Image(ImageBase, BasePathMixin): Functions for manipulating regions associated with this image. """ - IMAGE_TYPE = ImageType.FRAME + VIEW_TYPE = ImageType.FRAME - def __init__(self, session, file_id): + def __init__(self, session, image_id): super().__init__(session) - self.file_id = file_id + self.image_id = image_id - self._base_path = f"frameMap[{file_id}]" + self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) # Sub-objects grouping related functions @@ -65,7 +65,7 @@ def __init__(self, session, file_id): @property def _stable_id(self): - return self.file_id + return self.image_id @classmethod def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_active=True, update_directory=False): @@ -105,8 +105,8 @@ def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_ params.append(make_active) params.append(update_directory) - file_id = session.call_action(command, *params, return_path="frameInfo.fileId") - return cls(session, file_id) + image_id = session.call_action(command, *params, return_path="frameInfo.fileId") + return cls(session, image_id) def __repr__(self): """A human-readable representation of this image object.""" @@ -115,16 +115,16 @@ def __repr__(self): name_part = f", file_name={cached_name!r}" if cached_name is not None else "" try: - order = self.image_view_order + index = self.view_index except (CartaScriptingException, RuntimeError): - return f"[Closed] {cls}(image_view_order=None{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(view_index=None{name_part}, image_id={self.image_id})" try: name = self.file_name except CartaScriptingException: - return f"[Closed] {cls}(image_view_order={order}{name_part}, file_id={self.file_id})" + return f"[Closed] {cls}(view_index={index}{name_part}, image_id={self.image_id})" - return f"{cls}(image_view_order={order}, file_name={name!r}, file_id={self.file_id})" + return f"{cls}(view_index={index}, file_name={name!r}, image_id={self.image_id})" # METADATA diff --git a/carta/image_base.py b/carta/image_base.py deleted file mode 100644 index 81f0a0b..0000000 --- a/carta/image_base.py +++ /dev/null @@ -1,87 +0,0 @@ -"""This module contains the shared base class for image-view items (frame-backed images and color blendings). - -The class in this module should not be instantiated directly. It exists so that :obj:`carta.image.Image` and :obj:`carta.color_blending.ColorBlending` can share a common protocol without one having to import the other. -""" - - -from abc import ABC, abstractmethod - -from .constants import ImageType -from .validation import Constant, validate - - -class ImageBase(ABC): - """Base class for image-view items (frame-backed images and color blendings). - - This class is not intended to be instantiated directly. - - Attributes - ---------- - session : :obj:`carta.session.Session` - The session object associated with this image-view item. - """ - - CUSTOM_CLASS = {} - """Mapping of image-view types to their concrete wrapper classes.""" - - def __init_subclass__(cls, **kwargs): - """Register concrete image-view wrapper subclasses by image type.""" - super().__init_subclass__(**kwargs) - ImageBase.CUSTOM_CLASS[cls.IMAGE_TYPE] = cls - - def __init__(self, session): - self.session = session - - @property - @abstractmethod - def _stable_id(self): - """The stable identifier of this image-view item.""" - raise NotImplementedError # pragma: no cover - - @classmethod - @validate(Constant(ImageType)) - def image_class(cls, image_type): - """The image class associated with an image-view type. - - Parameters - ---------- - image_type : {0} - The image-view type. - - Returns - ------- - class object - The concrete image-view wrapper class. - """ - image_type = ImageType(image_type) - image_class = cls.CUSTOM_CLASS.get(image_type) - if image_class is None: - raise NotImplementedError( - f"No ImageBase subclass is registered for image-view type " - f"{image_type!r}." - ) - return image_class - - @property - def image_view_order(self): - """The current index of this item in image list. - - Returns - ------- - integer - The image view order. - - Raises - ------ - RuntimeError - If no matching entry exists in the image list. - """ - return self.session._find_image_view_order( - self.IMAGE_TYPE, self._stable_id - ) - - def make_active(self): - """Make this the active image-view item.""" - self.session.call_action( - "setActiveImageById", self.IMAGE_TYPE, self._stable_id - ) diff --git a/carta/session.py b/carta/session.py index 049179a..4f2679d 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,9 +10,9 @@ import posixpath from .image import Image -from .image_base import ImageBase +from .view import View from .color_blending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet +from .constants import PanelMode, GridMode, ComplexComponent, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt @@ -534,8 +534,8 @@ def open_hypercube(self, image_paths, append=False): output_directory = self.pwd() output_hdu = "" command = "appendConcatFile" if append else "openConcatFile" - file_id = self.call_action(command, stokes_images, output_directory, output_hdu) - return Image(self, file_id) + image_id = self.call_action(command, stokes_images, output_directory, output_hdu) + return Image(self, image_id) @validate(IterableOf(String(), min_size=1)) def open_as_color_blending(self, files): @@ -564,34 +564,45 @@ def open_as_color_blending(self, files): cb = self.create_color_blending() return cb - # IMAGE-VIEW ITEMS + # VIEWS - def image_list(self): - """Return the list of currently open image-view items. + @validate(NoneOr(IterableOf(Number.ID))) + def views(self, view_indices=None): + """Return all or selected currently open views. + + When no indices are supplied, all open views are returned. When + indices are supplied, the views at those positions are returned in + the requested order. Returns ------- - list of :obj:`carta.image_base.ImageBase` - The heterogeneous list of image-view items open in this session. + list of :obj:`carta.view.View` + The requested heterogeneous views open in this session. """ + if view_indices is not None: + return [ + self.view_by_id(view_index=view_index) + for view_index in view_indices + ] + summary = self.get_value("imageViewConfigStore.imageListSummary") result = [] - for order, entry in enumerate(summary): + for index, entry in enumerate(summary): try: result.append( - ImageBase.image_class(entry["type"])(self, entry["id"]) + View.view_class(entry["type"])(self, entry["id"]) ) except (CartaValidationFailed, NotImplementedError) as e: print( - f"Skipping unsupported image-view entry at order {order}: " + f"Skipping unsupported view entry at index {index}: " f"{entry!r}: {e}" ) return result - def _image_list(self, path, image_class, return_path): + def _view_list(self, path, view_class, return_path): count = self.get_value(f"{path}.length") return [ - image_class( + view_class( self, self.get_value( f"{path}[{index}]", @@ -602,27 +613,25 @@ def _image_list(self, path, image_class, return_path): ] @validate(NoneOr(IterableOf(Number.ID))) - def images(self, file_ids=None): - """Return frame-backed images from the session. + def images(self, image_ids=None): + """Return images from the session. - When no IDs are supplied, all open frame-backed images are returned. - When IDs are supplied, they are validated against the session's frame - map. + When no IDs are supplied, all open images are returned. When IDs are + supplied, they are validated against the session's image map. Parameters ---------- - file_ids : {0} - The file IDs of the images to return. By default, all open - frame-backed images are returned. + image_ids : {0} + The image IDs to return. By default, all open images are returned. Returns ------- list of :obj:`carta.image.Image` - The requested frame-backed images. + The requested images. """ - if file_ids is None: - return self._image_list("frames", Image, "frameInfo.fileId") - return [self.image_by_id(file_id=file_id) for file_id in file_ids] + if image_ids is None: + return self._view_list("frames", Image, "frameInfo.fileId") + return [self.view_by_id(image_id=image_id) for image_id in image_ids] @validate(NoneOr(IterableOf(Number.ID))) def color_blendings(self, color_blending_ids=None): @@ -644,74 +653,74 @@ def color_blendings(self, color_blending_ids=None): The requested color blending images. """ if color_blending_ids is None: - return self._image_list( + return self._view_list( "imageViewConfigStore.colorBlendingImages", ColorBlending, "id", ) return [ - self.image_by_id(color_blending_id=color_blending_id) + self.view_by_id(color_blending_id=color_blending_id) for color_blending_id in color_blending_ids ] - def _find_image_view_order(self, image_type, stable_id): - """Return the image-view order of an item identified by a stable id. + def _find_view_index(self, view_type, stable_id): + """Return the view index of an item identified by a stable id. Parameters ---------- - image_type : :obj:`carta.constants.ImageType` - The image-view item type. + view_type : :obj:`carta.constants.ImageType` + The view type. stable_id : integer - The stable id for that type (``file_id`` for frames, + The stable id for that type (``image_id`` for images, ``color_blending_id`` for color blendings). Returns ------- integer - The image-view order of the matching entry. + The view index of the matching entry. Raises ------ RuntimeError - If no matching entry exists in the image list. + If no matching entry exists in the views. """ - image_view_order = self.call_action( + view_index = self.call_action( "imageViewConfigStore.getImageListIndex", - image_type, + view_type, stable_id, response_expected=True, ) - if image_view_order == -1: + if view_index == -1: raise RuntimeError( - f"Could not find an image of type {image_type!r} with id " - f"{stable_id} in the image list." + f"Could not find a view of type {view_type!r} with id " + f"{stable_id} in the views." ) - return image_view_order + return view_index @validate(NoneOr(Number.ID), NoneOr(Number.ID), NoneOr(Number.ID)) - def image_by_id( - self, *, image_view_order=None, file_id=None, color_blending_id=None + def view_by_id( + self, *, view_index=None, image_id=None, color_blending_id=None ): - """Return the image-view item identified by exactly one of the supported identifiers. + """Return the view identified by exactly one supported identifier. Parameters ---------- - image_view_order : integer, optional - The index of the item in the image list. + view_index : integer, optional + The index of the item in the views. Returns whichever concrete wrapper (:obj:`carta.image.Image` or :obj:`carta.color_blending.ColorBlending`) matches the entry type at that position. Raises :obj:`NotImplementedError` for any future entry type that is not yet wrapped on the Python side. - file_id : integer, optional - The stable frontend file id of a normal frame-backed image. + image_id : integer, optional + The stable id of an image. color_blending_id : integer, optional The stable id of a color blending. Returns ------- - :obj:`carta.image_base.ImageBase` - The matching image-view item. + :obj:`carta.view.View` + The matching view. Raises ------ @@ -720,14 +729,14 @@ def image_by_id( The error message lists the three accepted keyword names so the API is discoverable from the exception alone. IndexError - If ``image_view_order`` is out of range. + If ``view_index`` is out of range. RuntimeError - If no matching entry exists for the given ``file_id`` or + If no matching entry exists for the given ``image_id`` or ``color_blending_id``. There is no cross-type fallback. """ provided = { - "image_view_order": image_view_order, - "file_id": file_id, + "view_index": view_index, + "image_id": image_id, "color_blending_id": color_blending_id, } provided_values = { @@ -737,35 +746,34 @@ def image_by_id( } if len(provided_values) != 1: raise ValueError( - "image_by_id requires exactly one of the keyword arguments " - "`image_view_order`, `file_id`, or `color_blending_id`; " + "view_by_id requires exactly one of the keyword arguments " + "`view_index`, `image_id`, or `color_blending_id`; " f"got {len(provided_values)} with values {provided_values!r}." ) - if image_view_order is not None: + if view_index is not None: try: entry = self.get_value( "imageViewConfigStore.imageListSummary" - f"[{image_view_order}]" + f"[{view_index}]" ) except (CartaActionFailed, CartaBadResponse) as e: raise IndexError( - f"image_view_order {image_view_order} is out of range " - "for the image list." + f"view_index {view_index} is out of range for the views." ) from e - return ImageBase.image_class(entry["type"])(self, entry["id"]) + return View.view_class(entry["type"])(self, entry["id"]) - if file_id is not None: + if image_id is not None: try: - resolved_file_id = self.get_value( - f"frameMap[{file_id}]", + resolved_image_id = self.get_value( + f"frameMap[{image_id}]", return_path="frameInfo.fileId", ) except (CartaActionFailed, CartaBadResponse) as e: raise RuntimeError( - f"No frame-backed image with file_id={file_id} is open." + f"No image with image_id={image_id} is open." ) from e - return Image(self, resolved_file_id) + return Image(self, resolved_image_id) # color_blending_id is not None try: @@ -781,25 +789,24 @@ def image_by_id( ) from e return ColorBlending(self, resolved_color_blending_id) - def active_image(self): - """Return the currently active image-view item. + def active_view(self): + """Return the currently active view. - This is the frame-backed image or color blending image that is - currently active in the viewer. + This is the image or color blending image that is currently active. Returns ------- :obj:`carta.image.Image` or :obj:`carta.color_blending.ColorBlending` - The currently active image-view item. + The currently active view. Raises ------ NotImplementedError - If the active image is of a type that is not yet wrapped on + If the active view is of a type that is not yet wrapped on the Python side. """ active = self.get_value("activeImage") - return ImageBase.image_class(active["type"])( + return View.view_class(active["type"])( self, active["store"]["id"] ) @@ -807,7 +814,7 @@ def active_image(self): def create_color_blending(self): """Create a new color blending from the current spatial reference - and its currently spatially matched frames. + and its currently spatially matched images. Returns ------- @@ -817,12 +824,12 @@ def create_color_blending(self): Raises ------ CartaActionFailed - If no frames are open or the frontend could not create the + If no images are open or the frontend could not create the color blending image. """ - frame_count = self.get_value("frames.length") - if frame_count <= 0: - raise CartaActionFailed("No frames are open.") + image_count = self.get_value("frames.length") + if image_count <= 0: + raise CartaActionFailed("No images are open.") color_blending_id = self.call_action( "imageViewConfigStore.createColorBlending", @@ -913,7 +920,7 @@ def set_cursor(self, x, y): @validate(NoneOr(Color())) def rendered_view_url(self, background_color=None): - """Get a data URL of the rendered active image. + """Get a data URL of the rendered active view. Parameters ---------- @@ -934,7 +941,7 @@ def rendered_view_url(self, background_color=None): @validate(NoneOr(Color())) def rendered_view_data(self, background_color=None): - """Get the decoded data of the rendered active image. + """Get the decoded data of the rendered active view. Parameters ---------- @@ -953,7 +960,7 @@ def rendered_view_data(self, background_color=None): @validate(String(), NoneOr(Color())) def save_rendered_view(self, file_name, background_color=None): - """Save the decoded data of the rendered active image to a file. + """Save the decoded data of the rendered active view to a file. Parameters ---------- diff --git a/carta/view.py b/carta/view.py new file mode 100644 index 0000000..3362637 --- /dev/null +++ b/carta/view.py @@ -0,0 +1,75 @@ +"""Shared wrapper for heterogeneous views in a CARTA session. + +The class in this module should not be instantiated directly. It exists so +that :obj:`carta.image.Image` and :obj:`carta.color_blending.ColorBlending` +can share a common protocol without one having to import the other. +""" + + +from abc import ABC, abstractmethod + +from .constants import ImageType +from .validation import Constant, validate + + +class View(ABC): + """Base class for views (images and color blendings). + + This class is not intended to be instantiated directly. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this view. + """ + + CUSTOM_CLASS = {} + """Mapping of view types to their concrete wrapper classes.""" + + def __init_subclass__(cls, **kwargs): + """Register concrete view wrapper subclasses by view type.""" + super().__init_subclass__(**kwargs) + View.CUSTOM_CLASS[cls.VIEW_TYPE] = cls + + def __init__(self, session): + self.session = session + + @property + @abstractmethod + def _stable_id(self): + """The stable identifier of this view.""" + raise NotImplementedError # pragma: no cover + + @classmethod + @validate(Constant(ImageType)) + def view_class(cls, view_type): + """The wrapper class associated with a view type. + + Parameters + ---------- + view_type : {0} + The view type. + + Returns + ------- + class object + The concrete view wrapper class. + """ + view_type = ImageType(view_type) + view_class = cls.CUSTOM_CLASS.get(view_type) + if view_class is None: + raise NotImplementedError( + f"No View subclass is registered for view type {view_type!r}." + ) + return view_class + + @property + def view_index(self): + """The current index of this view in the session's views.""" + return self.session._find_view_index(self.VIEW_TYPE, self._stable_id) + + def make_active(self): + """Make this view the active view.""" + self.session.call_action( + "setActiveImageById", self.VIEW_TYPE, self._stable_id + ) diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py index 6041f05..f59396c 100644 --- a/carta/wcs_overlay.py +++ b/carta/wcs_overlay.py @@ -435,14 +435,14 @@ class ImageWCSConnector: ANY_IDS = NoneOr(IterableOf(Number.ID)) - def _get_image_wcs_properties(self, file_ids, property_path): + def _get_image_wcs_properties(self, image_ids, property_path): """Internal helper function for fetching wcs properties from multiple images.""" - images = self.session.images(file_ids) + images = self.session.images(image_ids) return tuple(attrgetter(property_path)(image.wcs) for image in images) - def _call_image_wcs_functions(self, file_ids, function_path, *function_args): + def _call_image_wcs_functions(self, image_ids, function_path, *function_args): """Internal helper function for executing wcs functions on multiple images.""" - images = self.session.images(file_ids) + images = self.session.images(image_ids) for image in images: attrgetter(function_path)(image.wcs)(*function_args) @@ -535,12 +535,12 @@ class Title(HasCustomColor, HasCustomText, HasFont, HasVisibility, ImageWCSConne COMPONENT = Overlay.TITLE @validate(ImageWCSConnector.ANY_IDS) - def text(self, file_ids=None): + def text(self, image_ids=None): """The custom title text for the specified images. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. Returns @@ -548,10 +548,10 @@ def text(self, file_ids=None): tuple of string The title text of the specified images. """ - return self._get_image_wcs_properties(file_ids, "title.text") + return self._get_image_wcs_properties(image_ids, "title.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, title_text, file_ids=None): + def set_text(self, title_text, image_ids=None): """Set the custom title text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -560,10 +560,10 @@ def set_text(self, title_text, file_ids=None): ---------- title_text : {0} The custom title text for the specified images. - file_ids : {1} + image_ids : {1} The images to configure. """ - self._call_image_wcs_functions(file_ids, "title.set_text", title_text) + self._call_image_wcs_functions(image_ids, "title.set_text", title_text) class Grid(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): @@ -1047,12 +1047,12 @@ class ColorbarLabel(HasVisibility, HasCustomColor, HasCustomText, HasFont, HasRo PREFIX = "label" @validate(ImageWCSConnector.ANY_IDS) - def text(self, file_ids=None): + def text(self, image_ids=None): """The custom colorbar label text for the specified images. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. Returns @@ -1060,10 +1060,10 @@ def text(self, file_ids=None): tuple of string The colorbar label text of the specified images. """ - return self._get_image_wcs_properties(file_ids, "colorbar.label.text") + return self._get_image_wcs_properties(image_ids, "colorbar.label.text") @validate(String(), ImageWCSConnector.ANY_IDS) - def set_text(self, label_text, file_ids=None): + def set_text(self, label_text, image_ids=None): """Set the custom colorbar label text for the specified images. This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. @@ -1072,11 +1072,11 @@ def set_text(self, label_text, file_ids=None): ---------- label_text : {0} The custom colorbar label text for the specified images. - file_ids : {1} + image_ids : {1} The images to configure. """ - self._call_image_wcs_functions(file_ids, "colorbar.label.set_text", label_text) + self._call_image_wcs_functions(image_ids, "colorbar.label.set_text", label_text) class ColorbarGradient(HasVisibility, ColorbarComponent): @@ -1200,12 +1200,12 @@ class Beam(ImageWCSConnector, OverlayComponent): COMPONENT = Overlay.BEAM @validate(ImageWCSConnector.ANY_IDS) - def position(self, file_ids=None): + def position(self, image_ids=None): """The beam position. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1213,15 +1213,15 @@ def position(self, file_ids=None): tuple of (number, number) tuples The X and Y beam positions of the specified images, in pixels. """ - return self._get_image_wcs_properties(file_ids, "beam.position") + return self._get_image_wcs_properties(image_ids, "beam.position") @validate(ImageWCSConnector.ANY_IDS) - def type(self, file_ids=None): + def type(self, image_ids=None): """The beam type. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1229,15 +1229,15 @@ def type(self, file_ids=None): tuple of members of :obj:`carta.constants.BeamType` The beam types of the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.type") + return self._get_image_wcs_properties(image_ids, "beam.type") @validate(ImageWCSConnector.ANY_IDS) - def color(self, file_ids=None): + def color(self, image_ids=None): """The color of this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1245,15 +1245,15 @@ def color(self, file_ids=None): tuple of members of :obj:`carta.constants.color.PaletteColor` The colors of the beam in the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.color") + return self._get_image_wcs_properties(image_ids, "beam.color") @validate(ImageWCSConnector.ANY_IDS) - def visible(self, file_ids=None): + def visible(self, image_ids=None): """The visibility of this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1261,15 +1261,15 @@ def visible(self, file_ids=None): tuple of boolean Whether the beam is visible in the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.visible") + return self._get_image_wcs_properties(image_ids, "beam.visible") @validate(ImageWCSConnector.ANY_IDS) - def width(self, file_ids=None): + def width(self, image_ids=None): """The width of this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to query. By default, values will be returned for all images. Returns @@ -1277,10 +1277,10 @@ def width(self, file_ids=None): tuple of boolean The width of the beam in the specified images. """ - return self._get_image_wcs_properties(file_ids, "beam.width") + return self._get_image_wcs_properties(image_ids, "beam.width") @validate(*all_optional(Number(), Number(), ImageWCSConnector.ANY_IDS)) - def set_position(self, position_x=None, position_y=None, file_ids=None): + def set_position(self, position_x=None, position_y=None, image_ids=None): """Set the beam position. Parameters @@ -1289,84 +1289,84 @@ def set_position(self, position_x=None, position_y=None, file_ids=None): The X position, in pixels. position_y : {1} The Y position, in pixels. - file_ids : {2} + image_ids : {2} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_position", position_x, position_y) + self._call_image_wcs_functions(image_ids, "beam.set_position", position_x, position_y) @validate(Constant(BeamType), ImageWCSConnector.ANY_IDS) - def set_type(self, beam_type, file_ids=None): + def set_type(self, beam_type, image_ids=None): """Set the beam type. Parameters ---------- beam_type : {0} The beam type. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_type", beam_type) + self._call_image_wcs_functions(image_ids, "beam.set_type", beam_type) @validate(Constant(PaletteColor), ImageWCSConnector.ANY_IDS) - def set_color(self, color, file_ids=None): + def set_color(self, color, image_ids=None): """Set the color of this component. Parameters ---------- color : {0} The color. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_color", color) + self._call_image_wcs_functions(image_ids, "beam.set_color", color) @validate(Boolean(), ImageWCSConnector.ANY_IDS) - def set_visible(self, state, file_ids=None): + def set_visible(self, state, image_ids=None): """Set the visibility of this component. Parameters ---------- visible : {0} Whether this component should be visible. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_visible", state) + self._call_image_wcs_functions(image_ids, "beam.set_visible", state) @validate(ImageWCSConnector.ANY_IDS) - def show(self, file_ids=None): + def show(self, image_ids=None): """Show this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(True, file_ids) + self.set_visible(True, image_ids) @validate(ImageWCSConnector.ANY_IDS) - def hide(self, file_ids=None): + def hide(self, image_ids=None): """Hide this component. Parameters ---------- - file_ids : {0} + image_ids : {0} The images to configure. By default, the settings will be changed for all images. """ - self.set_visible(False, file_ids) + self.set_visible(False, image_ids) @validate(Number.POSITIVE, ImageWCSConnector.ANY_IDS) - def set_width(self, width, file_ids=None): + def set_width(self, width, image_ids=None): """Set the width of this component. Parameters ---------- width : {0} The width. - file_ids : {1} + image_ids : {1} The images to configure. By default, the settings will be changed for all images. """ - self._call_image_wcs_functions(file_ids, "beam.set_width", width) + self._call_image_wcs_functions(image_ids, "beam.set_width", width) class ImageWCSOverlay(BasePathMixin): diff --git a/docs/source/carta.rst b/docs/source/carta.rst index ab20c1d..9ab1ea6 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -49,10 +49,10 @@ carta.image module :undoc-members: :show-inheritance: -carta.image\_base module ------------------------- +carta.view module +----------------- -.. automodule:: carta.image_base +.. automodule:: carta.view :members: :undoc-members: :show-inheritance: diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 0e03383..8ea84c0 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -189,36 +189,39 @@ Helper methods on the session object open images in the frontend and return imag Inspecting the list of open images ---------------------------------- -The session's image list is heterogeneous: it may contain both ordinary frame-backed images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.color_blending.ColorBlending`). Its order matches the image list panel shown in the frontend, as illustrated below. +The session's views are heterogeneous: they may contain both ordinary images (:obj:`carta.image.Image`) and color blending images (:obj:`carta.color_blending.ColorBlending`). Their order matches the views panel shown in the frontend, as illustrated below. .. figure:: images/image_list.jpg - :alt: CARTA frontend image list panel showing frame-backed images and a color blending entry. + :alt: CARTA frontend views panel showing images and a color blending entry. :align: center - The frontend image list panel. Each row corresponds to an item returned by :obj:`carta.session.Session.image_list`, and its position in the list is the item's ``image_view_order``. + The frontend views panel. Each row corresponds to an item returned by :obj:`carta.session.Session.views`, and its position in the list is the item's ``view_index``. Use :meth:`carta.session.Session.images` and :meth:`carta.session.Session.color_blendings` to retrieve the two concrete -image-view types directly, optionally filtering by their stable IDs. +view types directly, optionally filtering by their stable IDs. .. code-block:: python - # All open image-view items, in display order - items = session.image_list() + # All open views, in display order + items = session.views() - # Get frame-backed images or color blending images directly + # Select views by their current indices, preserving the requested order + selected = session.views(view_indices=[1, 0]) + + # Get images or color blending images directly images = session.images() color_blendings = session.color_blendings() - # Every image-view item exposes its current image-view order - print(img0.image_view_order) + # Every view exposes its current view index + print(img0.view_index) - # Retrieve a specific item by image view order - img = session.image_by_id(image_view_order=0) - cb = session.image_by_id(image_view_order=1) + # Retrieve a specific view by view index + img = session.view_by_id(view_index=0) + cb = session.view_by_id(view_index=1) # Filter by stable IDs when needed - images = session.images(file_ids=[img0.file_id, img1.file_id]) + images = session.images(image_ids=[img0.image_id, img1.image_id]) color_blendings = session.color_blendings( color_blending_ids=[cb.color_blending_id] ) @@ -295,7 +298,7 @@ The session object provides two convenience methods which create a color blendin cb = session.open_as_color_blending(files) # Create a new color blending from the current spatial reference - # and its currently spatially matched frames. + # and its currently spatially matched images. # Set the desired base image as the current spatial reference and # enable spatial matching for the other layers first. session.clear_spatial_reference() @@ -368,10 +371,10 @@ Manipulate properties of the color blending object and the underlying layers: # Set zoom level cb.set_zoom_level(2) - # Get the current image-view order of the color blending image - print(cb.image_view_order) + # Get the current view index of the color blending image + print(cb.view_index) - # Set the color blending object as the active image-view item + # Set the color blending object as the active view cb.make_active() # Set contour visibility @@ -422,8 +425,8 @@ Closing images .. code-block:: python - # Close all image-view items open in the session - for item in session.image_list(): + # Close all views open in the session + for item in session.views(): item.close() Closing the session diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 8897b48..470560b 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -71,46 +71,46 @@ def test_layer_from_list(color_blending): def test_layer_repr_healthy(session, color_blending, layer_property, mocker): - find = mocker.patch.object(session, "_find_image_view_order", return_value=2) - layer_property("file_id", 42) + find = mocker.patch.object(session, "_find_view_index", return_value=2) + layer_property("image_id", 42) layer_property("file_name", "layer1.fits") layer_property("colormap", "viridis") layer_property("inverted", False) layer_property("alpha", 0.5) r = repr(Layer(color_blending, 3)) assert r == ( - "Layer(image_view_order=2, color_blending_id=0, layer_id=3, " + "Layer(view_index=2, color_blending_id=0, layer_id=3, " "file_name='layer1.fits', colormap='viridis', inverted=False, " "alpha=0.5)" ) find.assert_called_once_with(ImageType.FRAME, 42) -def test_layer_repr_closed_when_frame_not_in_image_list( +def test_layer_repr_closed_when_image_not_in_views( session, color_blending, layer_property, mocker ): - layer_property("file_id", 42) + layer_property("image_id", 42) mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) r = repr(Layer(color_blending, 3)) assert r == ( - "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "[Closed] Layer(view_index=None, color_blending_id=0, " "layer_id=3)" ) -def test_layer_repr_closed_when_frame_is_gone(session, color_blending, mocker): +def test_layer_repr_closed_when_image_is_gone(session, color_blending, mocker): mocker.patch( - "carta.color_blending.Layer.file_id", + "carta.color_blending.Layer.image_id", new_callable=mocker.PropertyMock, side_effect=CartaActionFailed("frame is gone"), ) r = repr(Layer(color_blending, 3)) assert r == ( - "[Closed] Layer(image_view_order=None, color_blending_id=0, " + "[Closed] Layer(view_index=None, color_blending_id=0, " "layer_id=3)" ) @@ -118,8 +118,8 @@ def test_layer_repr_closed_when_frame_is_gone(session, color_blending, mocker): def test_layer_repr_closed_when_file_name_read_fails( session, color_blending, layer_property, mocker ): - mocker.patch.object(session, "_find_image_view_order", return_value=2) - layer_property("file_id", 42) + mocker.patch.object(session, "_find_view_index", return_value=2) + layer_property("image_id", 42) mocker.patch( "carta.color_blending.Layer.file_name", new_callable=mocker.PropertyMock, @@ -127,7 +127,7 @@ def test_layer_repr_closed_when_file_name_read_fails( ) r = repr(Layer(color_blending, 3)) assert r == ( - "[Closed] Layer(image_view_order=2, color_blending_id=0, " + "[Closed] Layer(view_index=2, color_blending_id=0, " "layer_id=3)" ) @@ -137,8 +137,8 @@ def test_layer_file_name_property(layer, layer_get_value): layer_get_value.assert_called_with("frameInfo.fileInfo.name") -def test_layer_file_id_property(layer, layer_get_value): - layer.file_id +def test_layer_image_id_property(layer, layer_get_value): + layer.image_id layer_get_value.assert_called_with("frameInfo.fileId") @@ -192,24 +192,24 @@ def test_layer_set_image_rejects_invalid_image(layer, mocker): set_layer_image.assert_not_called() -def test_layer_image_view_order(session, color_blending, layer_property, mocker): - find = mocker.patch.object(session, "_find_image_view_order", return_value=7) - layer_property("file_id", 42) - assert Layer(color_blending, 3).image_view_order == 7 +def test_layer_view_index(session, color_blending, layer_property, mocker): + find = mocker.patch.object(session, "_find_view_index", return_value=7) + layer_property("image_id", 42) + assert Layer(color_blending, 3).view_index == 7 find.assert_called_once_with(ImageType.FRAME, 42) -def test_layer_image_view_order_raises_when_frame_not_in_image_list( +def test_layer_view_index_raises_when_image_not_in_views( session, color_blending, layer_property, mocker ): - layer_property("file_id", 42) + layer_property("image_id", 42) mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) with pytest.raises(RuntimeError): - Layer(color_blending, 3).image_view_order + Layer(color_blending, 3).view_index @pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0]) @@ -256,22 +256,22 @@ def test_color_blending_repr_healthy(session, color_blending, cb_property, mocke cb_property("file_name", "Color Blending 1") r = repr(color_blending) assert r == ( - "ColorBlending(image_view_order=2, color_blending_id=0, " + "ColorBlending(view_index=2, color_blending_id=0, " "file_name='Color Blending 1')" ) -def test_color_blending_repr_closed_when_not_in_image_list( +def test_color_blending_repr_closed_when_not_in_views( session, color_blending, mocker ): mocker.patch.object( session, "call_action", - side_effect=RuntimeError("not in image list"), + side_effect=RuntimeError("not in views"), ) r = repr(color_blending) assert r == ( - "[Closed] ColorBlending(image_view_order=None, color_blending_id=0)" + "[Closed] ColorBlending(view_index=None, color_blending_id=0)" ) @@ -286,7 +286,7 @@ def test_color_blending_repr_closed_when_backing_entry_is_gone( ) r = repr(color_blending) assert r == ( - "[Closed] ColorBlending(image_view_order=2, color_blending_id=0)" + "[Closed] ColorBlending(view_index=2, color_blending_id=0)" ) @@ -295,11 +295,11 @@ def test_color_blending_file_name(color_blending, cb_get_value): cb_get_value.assert_called_with("filename") -def test_color_blending_image_view_order( +def test_color_blending_view_index( session, color_blending, session_call_action ): session_call_action.return_value = 2 - assert color_blending.image_view_order == 2 + assert color_blending.view_index == 2 session_call_action.assert_called_once_with( "imageViewConfigStore.getImageListIndex", ImageType.COLOR_BLENDING, @@ -308,12 +308,12 @@ def test_color_blending_image_view_order( ) -def test_color_blending_image_view_order_raises_when_missing( +def test_color_blending_view_index_raises_when_missing( session, color_blending, session_call_action ): session_call_action.return_value = -1 with pytest.raises(RuntimeError): - color_blending.image_view_order + color_blending.view_index def test_color_blending_alpha(color_blending, cb_get_value): @@ -328,19 +328,19 @@ def test_color_blending_base_frame(color_blending, cb_get_value): cb_get_value.assert_called_once_with("frames[0].id") assert isinstance(base_frame, Image) assert base_frame.session is color_blending.session - assert base_frame.file_id == 42 + assert base_frame.image_id == 42 def test_color_blending_make_active(session, color_blending, session_call_action): # make_active must be driven by color_blending_id via setActiveImageById. - # It must not depend on image_view_order (which is volatile). + # It must not depend on view_index (which is volatile). color_blending.make_active() session_call_action.assert_called_with( "setActiveImageById", ImageType.COLOR_BLENDING, 0 ) -def test_color_blending_make_active_does_not_read_image_view_order( +def test_color_blending_make_active_does_not_read_view_index( session, color_blending, session_call_action, session_get_value ): color_blending.make_active() @@ -400,7 +400,7 @@ def test_color_blending_delete_base_layer_promotes_next_layer( layers = [Layer(color_blending, 0), Layer(color_blending, 1)] mocker.patch.object(color_blending, "layer_list", return_value=layers) mocker.patch.object( - Layer, "file_id", new_callable=mocker.PropertyMock, return_value=42 + Layer, "image_id", new_callable=mocker.PropertyMock, return_value=42 ) image = mocker.patch("carta.color_blending.Image", autospec=True) diff --git a/tests/test_image.py b/tests/test_image.py index 33ef995..1627c3b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,6 +1,7 @@ import pytest -from carta.image import Image, ImageBase +from carta.image import Image +from carta.view import View from carta.util import CartaActionFailed, CartaValidationFailed, Point as Pt from carta.constants import ImageType, NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU @@ -77,7 +78,7 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte assert type(image_object) is Image assert image_object.session == session - assert image_object.file_id == 123 + assert image_object.image_id == 123 # SUBOBJECTS @@ -124,105 +125,112 @@ def test_make_spatial_reference_disables_color_blending_alert( ) -def test_image_base_is_abstract(session): +def test_view_is_abstract(session): with pytest.raises(TypeError, match=r"abstract method.*_stable_id"): - ImageBase(session) + View(session) -def test_image_base_make_active_uses_subclass_ids(session, session_call_action): - # Verify the shared ImageBase.make_active dispatches setActiveImageById - # with Image.IMAGE_TYPE and its stable ID exactly once. +def test_view_make_active_uses_subclass_ids(session, session_call_action): + # Verify the shared View.make_active dispatches setActiveImageById + # with Image.VIEW_TYPE and its stable ID exactly once. Image(session, 42).make_active() session_call_action.assert_called_once_with( "setActiveImageById", ImageType.FRAME, 42 ) -def test_image_base_image_view_order_uses_subclass_ids(session, mocker): - find = mocker.patch.object(session, "_find_image_view_order", return_value=5) +def test_view_class_resolves_registered_subclasses(): from carta.color_blending import ColorBlending - assert ColorBlending(session, 42).image_view_order == 5 + assert View.view_class(ImageType.FRAME) is Image + assert View.view_class(ImageType.COLOR_BLENDING) is ColorBlending + + +def test_view_index_uses_subclass_ids(session, mocker): + find = mocker.patch.object(session, "_find_view_index", return_value=5) + from carta.color_blending import ColorBlending + + assert ColorBlending(session, 42).view_index == 5 find.assert_called_once_with(ImageType.COLOR_BLENDING, 42) -def test_image_base_subclass_requires_image_type(): +def test_view_subclass_requires_view_type(): with pytest.raises( - AttributeError, match="has no attribute 'IMAGE_TYPE'" + AttributeError, match="has no attribute 'VIEW_TYPE'" ): - class Dummy(ImageBase): + class Dummy(View): pass -def test_image_view_order_uses_find_image_view_order(session, mocker, image): +def test_view_index_uses_find_view_index(session, mocker, image): find = mocker.patch.object( - session, "_find_image_view_order", return_value=3 + session, "_find_view_index", return_value=3 ) - # Frame with file_id=0 at viewer order 3. - assert image.image_view_order == 3 + # Image with image_id=0 at view index 3. + assert image.view_index == 3 find.assert_called_once_with(ImageType.FRAME, 0) -def test_image_view_order_raises_when_missing(session, mocker): +def test_view_index_raises_when_missing(session, mocker): mocker.patch.object( - session, "_find_image_view_order", side_effect=RuntimeError + session, "_find_view_index", side_effect=RuntimeError ) img = Image(session, 3) with pytest.raises(RuntimeError): - img.image_view_order + img.view_index -def test_image_repr_cached_name_resolves_only_image_view_order(session, image, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=3) +def test_image_repr_cached_name_resolves_only_view_index(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) get_value = mocker.patch.object(image, "get_value") image._cache = {"file_name": "cube.fits"} r = repr(image) - assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + assert r == "Image(view_index=3, file_name='cube.fits', image_id=0)" get_value.assert_not_called() -def test_image_repr_resolves_image_view_order_and_file_name(session, image, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=3) +def test_image_repr_resolves_view_index_and_file_name(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) mocker.patch.object(image, "get_value", return_value="cube.fits") r = repr(image) - assert r == "Image(image_view_order=3, file_name='cube.fits', file_id=0)" + assert r == "Image(view_index=3, file_name='cube.fits', image_id=0)" -def test_image_repr_closed_when_image_view_order_missing(session, image, mocker): +def test_image_repr_closed_when_view_index_missing(session, image, mocker): mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) r = repr(image) - assert r == "[Closed] Image(image_view_order=None, file_id=0)" + assert r == "[Closed] Image(view_index=None, image_id=0)" def test_image_repr_closed_shows_cached_file_name(session, image, mocker): - # When the image-view-order lookup fails but file_name was previously + # When the view-index lookup fails but file_name was previously # cached, the closed repr still surfaces the cached name without # triggering any fresh round-trip. mocker.patch.object( session, - "_find_image_view_order", - side_effect=RuntimeError("not in image list"), + "_find_view_index", + side_effect=RuntimeError("not in views"), ) get_value = mocker.patch.object(image, "get_value") image._cache = {"file_name": "cube.fits"} r = repr(image) - assert r == "[Closed] Image(image_view_order=None, file_name='cube.fits', file_id=0)" + assert r == "[Closed] Image(view_index=None, file_name='cube.fits', image_id=0)" get_value.assert_not_called() -def test_image_repr_closed_when_frame_is_gone(session, image, mocker): - mocker.patch.object(session, "_find_image_view_order", return_value=3) +def test_image_repr_closed_when_image_is_gone(session, image, mocker): + mocker.patch.object(session, "_find_view_index", return_value=3) mocker.patch.object( image, "get_value", - side_effect=CartaActionFailed("frameMap entry is missing"), + side_effect=CartaActionFailed("imageMap entry is missing"), ) r = repr(image) - assert r == "[Closed] Image(image_view_order=3, file_id=0)" + assert r == "[Closed] Image(view_index=3, image_id=0)" @pytest.mark.parametrize("channel", [0, 10, 19]) diff --git a/tests/test_session.py b/tests/test_session.py index 58aacb1..401c408 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -117,57 +117,79 @@ def test_cd(session, method, call_action): call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") -# IMAGE LIST / GET_IMAGE / COLOR-BLENDING HELPERS +# VIEWS / IMAGES / COLOR-BLENDING HELPERS -def test_image_list_heterogeneous(session, get_value): +def test_views_heterogeneous(session, get_value): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, {"type": ImageType.COLOR_BLENDING, "id": 3}, {"type": ImageType.FRAME, "id": 20}, ] - images = session.image_list() + views = session.views() get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary" ) - assert len(images) == 3 - assert isinstance(images[0], Image) and images[0].file_id == 10 - assert isinstance(images[1], ColorBlending) and images[1].color_blending_id == 3 - assert isinstance(images[2], Image) and images[2].file_id == 20 + assert len(views) == 3 + assert isinstance(views[0], Image) and views[0].image_id == 10 + assert isinstance(views[1], ColorBlending) and views[1].color_blending_id == 3 + assert isinstance(views[2], Image) and views[2].image_id == 20 -def test_image_list_skips_unsupported_image_type(session, get_value, capsys): +def test_views_skips_unsupported_view_type(session, get_value, capsys): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, {"type": 99, "id": 11}, {"type": ImageType.FRAME, "id": 20}, ] - images = session.image_list() + views = session.views() - assert [image.file_id for image in images] == [10, 20] + assert [view.image_id for view in views] == [10, 20] assert ( - "Skipping unsupported image-view entry at order 1" + "Skipping unsupported view entry at index 1" in capsys.readouterr().out ) -def test_image_list_empty(session, get_value): +def test_views_empty(session, get_value): get_value.return_value = [] - assert session.image_list() == [] + assert session.views() == [] get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary" ) -def test_images_uses_frame_list(session, get_value): +def test_views_uses_explicit_indices(session, mocker): + view_by_id = mocker.patch.object(session, "view_by_id") + view_by_id.side_effect = ["view-2", "view-0", "view-2-again"] + + views = session.views(view_indices=[2, 0, 2]) + + assert views == ["view-2", "view-0", "view-2-again"] + assert view_by_id.call_args_list == [ + call(view_index=2), + call(view_index=0), + call(view_index=2), + ] + + +@pytest.mark.parametrize("view_indices", [[-1], [1.5], ["1"]]) +def test_views_rejects_invalid_indices(session, get_value, view_indices): + with pytest.raises(CartaValidationFailed): + session.views(view_indices=view_indices) + + get_value.assert_not_called() + + +def test_images_uses_frontend_image_array(session, get_value): get_value.side_effect = [2, 10, 20] images = session.images() - assert [image.file_id for image in images] == [10, 20] + assert [image.image_id for image in images] == [10, 20] assert get_value.call_args_list == [ call("frames.length"), call("frames[0]", return_path="frameInfo.fileId"), @@ -175,16 +197,16 @@ def test_images_uses_frame_list(session, get_value): ] -def test_images_uses_frame_map_for_explicit_ids(session, mocker): - image_by_id = mocker.patch.object(session, "image_by_id") - image_by_id.side_effect = [object(), object()] +def test_images_uses_image_map_for_explicit_ids(session, mocker): + view_by_id = mocker.patch.object(session, "view_by_id") + view_by_id.side_effect = [object(), object()] - images = session.images(file_ids=[10, 20]) + images = session.images(image_ids=[10, 20]) assert len(images) == 2 - assert image_by_id.call_args_list == [ - call(file_id=10), - call(file_id=20), + assert view_by_id.call_args_list == [ + call(image_id=10), + call(image_id=20), ] @@ -213,23 +235,23 @@ def test_color_blendings_uses_color_blending_list(session, get_value): def test_color_blendings_uses_color_blending_map_for_explicit_ids( session, mocker ): - image_by_id = mocker.patch.object(session, "image_by_id") - image_by_id.side_effect = [object(), object()] + view_by_id = mocker.patch.object(session, "view_by_id") + view_by_id.side_effect = [object(), object()] color_blendings = session.color_blendings([3, 7]) assert len(color_blendings) == 2 - assert image_by_id.call_args_list == [ + assert view_by_id.call_args_list == [ call(color_blending_id=3), call(color_blending_id=7), ] -def test_find_image_view_order_single_round_trip(session, call_action): +def test_find_view_index_single_round_trip(session, call_action): call_action.side_effect = [2, 1] - assert session._find_image_view_order(ImageType.FRAME, 3) == 2 - assert session._find_image_view_order(ImageType.COLOR_BLENDING, 7) == 1 + assert session._find_view_index(ImageType.FRAME, 3) == 2 + assert session._find_view_index(ImageType.COLOR_BLENDING, 7) == 1 assert call_action.call_args_list == [ call( "imageViewConfigStore.getImageListIndex", @@ -246,45 +268,45 @@ def test_find_image_view_order_single_round_trip(session, call_action): ] -def test_find_image_view_order_raises_when_missing(session, call_action): +def test_find_view_index_raises_when_missing(session, call_action): call_action.return_value = -1 with pytest.raises(RuntimeError): - session._find_image_view_order(ImageType.FRAME, 99) + session._find_view_index(ImageType.FRAME, 99) -# session.image_by_id +# session.view_by_id -def test_image_by_id_requires_exactly_one_keyword(session, get_value): +def test_view_by_id_requires_exactly_one_keyword(session, get_value): # Zero keywords -> ValueError with all three names listed. with pytest.raises(ValueError) as e: - session.image_by_id() - for name in ("image_view_order", "file_id", "color_blending_id"): + session.view_by_id() + for name in ("view_index", "image_id", "color_blending_id"): assert name in str(e.value) assert "got 0 with values {}" in str(e.value) # Multiple keywords -> ValueError. with pytest.raises(ValueError) as e: - session.image_by_id(file_id=1, color_blending_id=2) - assert "'file_id': 1" in str(e.value) + session.view_by_id(image_id=1, color_blending_id=2) + assert "'image_id': 1" in str(e.value) assert "'color_blending_id': 2" in str(e.value) -def test_image_by_id_rejects_positional(session): +def test_view_by_id_rejects_positional(session): with pytest.raises(TypeError): - session.image_by_id(0) + session.view_by_id(0) @pytest.mark.parametrize("keyword", [ - "image_view_order", - "file_id", + "view_index", + "image_id", "color_blending_id", ]) @pytest.mark.parametrize("value", [-1, 1.5, "1"]) -def test_image_by_id_rejects_invalid_identifier( +def test_view_by_id_rejects_invalid_identifier( session, get_value, keyword, value ): with pytest.raises(CartaValidationFailed): - session.image_by_id(**{keyword: value}) + session.view_by_id(**{keyword: value}) get_value.assert_not_called() @@ -300,64 +322,64 @@ def test_image_by_id_rejects_invalid_identifier( ), ], ) -def test_image_by_id_by_image_view_order( +def test_view_by_id_by_view_index( session, get_value, entry, expected_type, expected_id ): get_value.return_value = entry - img = session.image_by_id(image_view_order=0) + img = session.view_by_id(view_index=0) assert isinstance(img, expected_type) assert ( - img.file_id if expected_type is Image else img.color_blending_id + img.image_id if expected_type is Image else img.color_blending_id ) == expected_id get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary[0]" ) -def test_image_by_id_by_image_view_order_out_of_range(session, get_value): +def test_view_by_id_by_view_index_out_of_range(session, get_value): get_value.side_effect = CartaBadResponse("undefined") with pytest.raises(IndexError): - session.image_by_id(image_view_order=99) + session.view_by_id(view_index=99) get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary[99]" ) -def test_image_by_id_by_image_view_order_raises_on_unsupported_type( +def test_view_by_id_by_view_index_raises_on_unsupported_type( session, get_value ): get_value.return_value = {"type": ImageType.PV_PREVIEW, "id": -2} with pytest.raises(NotImplementedError): - session.image_by_id(image_view_order=0) + session.view_by_id(view_index=0) -def test_image_by_id_by_file_id(session, get_value): +def test_view_by_id_by_image_id(session, get_value): get_value.return_value = 20 - img = session.image_by_id(file_id=20) + img = session.view_by_id(image_id=20) assert isinstance(img, Image) - assert img.file_id == 20 + assert img.image_id == 20 get_value.assert_called_once_with( "frameMap[20]", return_path="frameInfo.fileId", ) -def test_image_by_id_by_file_id_no_cross_type_fallback(session, get_value): +def test_view_by_id_by_image_id_no_cross_type_fallback(session, get_value): get_value.side_effect = CartaBadResponse("undefined") with pytest.raises(RuntimeError): - session.image_by_id(file_id=7) + session.view_by_id(image_id=7) -def test_image_by_id_by_color_blending_id(session, get_value): +def test_view_by_id_by_color_blending_id(session, get_value): get_value.return_value = 7 - cb = session.image_by_id(color_blending_id=7) + cb = session.view_by_id(color_blending_id=7) assert isinstance(cb, ColorBlending) assert cb.color_blending_id == 7 get_value.assert_called_once_with( @@ -366,25 +388,25 @@ def test_image_by_id_by_color_blending_id(session, get_value): ) -def test_image_by_id_by_color_blending_id_no_cross_type_fallback( +def test_view_by_id_by_color_blending_id_no_cross_type_fallback( session, get_value ): get_value.side_effect = CartaBadResponse("undefined") with pytest.raises(RuntimeError): - session.image_by_id(color_blending_id=10) + session.view_by_id(color_blending_id=10) -def test_image_by_id_uses_targeted_frontend_lookups(session, get_value): +def test_view_by_id_uses_targeted_frontend_lookups(session, get_value): get_value.side_effect = [ {"type": ImageType.FRAME, "id": 10}, 10, 7, ] - session.image_by_id(image_view_order=0) - session.image_by_id(file_id=10) - session.image_by_id(color_blending_id=7) + session.view_by_id(view_index=0) + session.view_by_id(image_id=10) + session.view_by_id(color_blending_id=7) assert get_value.call_args_list == [ call( @@ -398,28 +420,28 @@ def test_image_by_id_uses_targeted_frontend_lookups(session, get_value): ] -# session.active_image +# session.active_view -def test_active_image_returns_image_when_frame_active(session, get_value): +def test_active_view_returns_image_when_image_active(session, get_value): get_value.side_effect = [ {"type": ImageType.FRAME, "store": {"id": 12}}, ] - active = session.active_image() + active = session.active_view() assert isinstance(active, Image) - assert active.file_id == 12 + assert active.image_id == 12 assert [call.args for call in get_value.call_args_list] == [ ("activeImage",), ] -def test_active_image_returns_color_blending_when_color_blending_active( +def test_active_view_returns_color_blending_when_color_blending_active( session, get_value ): get_value.side_effect = [ {"type": ImageType.COLOR_BLENDING, "store": {"id": 3}}, ] - active = session.active_image() + active = session.active_view() assert isinstance(active, ColorBlending) assert active.color_blending_id == 3 assert [call.args for call in get_value.call_args_list] == [ @@ -427,12 +449,12 @@ def test_active_image_returns_color_blending_when_color_blending_active( ] -def test_active_image_raises_on_unsupported_type(session, get_value): +def test_active_view_raises_on_unsupported_type(session, get_value): get_value.side_effect = [ {"type": ImageType.PV_PREVIEW, "store": {"id": -2}}, ] with pytest.raises(NotImplementedError): - session.active_image() + session.active_view() # open_as_color_blending / create_color_blending @@ -484,18 +506,18 @@ def test_open_as_color_blending_rejects_empty_file_list(session, mocker): create_color_blending.assert_not_called() -@pytest.mark.parametrize("open_frame_count,layer_count,expected_colormap_set", [ +@pytest.mark.parametrize("open_image_count,layer_count,expected_colormap_set", [ (1, 1, ColormapSet.RGB), (3, 3, ColormapSet.RGB), (5, 3, ColormapSet.RGB), (5, 4, ColormapSet.RAINBOW), ]) -def test_create_color_blending_calls_frontend_create_action(session, mocker, open_frame_count, layer_count, expected_colormap_set): +def test_create_color_blending_calls_frontend_create_action(session, mocker, open_image_count, layer_count, expected_colormap_set): get_value = mocker.patch.object( session, "get_value", side_effect=[ - open_frame_count, + open_image_count, layer_count, ], ) @@ -523,12 +545,12 @@ def test_create_color_blending_calls_frontend_create_action(session, mocker, ope assert result.color_blending_id == 123 -def test_create_color_blending_raises_when_no_frames_are_open(session, mocker): +def test_create_color_blending_raises_when_no_images_are_open(session, mocker): get_value = mocker.patch.object(session, "get_value", return_value=0) call_action = mocker.patch.object(session, "call_action") mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") - with pytest.raises(CartaActionFailed, match="No frames are open"): + with pytest.raises(CartaActionFailed, match="No images are open"): session.create_color_blending() get_value.assert_called_once_with("frames.length") @@ -651,7 +673,7 @@ def test_open_hypercube_guess_polarization(mocker, session, call_action, method, assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.file_id == 123 + assert hypercube.image_id == 123 @pytest.mark.parametrize("paths,expected_calls,mocked_side_effect,expected_error", [ @@ -705,7 +727,7 @@ def test_open_hypercube_explicit_polarization(mocker, session, call_action, meth assert type(hypercube) is Image assert hypercube.session == session - assert hypercube.file_id == 123 + assert hypercube.image_id == 123 @pytest.mark.parametrize("paths,expected_error", [ From 189a3a7bc051fe9cfbe986bb0189d0b0f1480926 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 21:50:31 +0800 Subject: [PATCH 90/95] Rename alpha to alphas and layer_list to layers, add optional layer_ids parameter to layers method for selective layer retrieval with validation --- carta/color_blending.py | 31 ++++++++++++++----- docs/source/quickstart.rst | 8 ++--- tests/test_color_blending.py | 59 ++++++++++++++++++++++++++++-------- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index ace85cc..0357c20 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -11,6 +11,7 @@ InstanceOf, IterableOf, Number, + NoneOr, Size, Attr, Evaluate, @@ -290,7 +291,7 @@ def file_name(self): # LAYERS @property - def alpha(self): + def alphas(self): """The alpha value list for the color blending layers. Returns @@ -314,7 +315,7 @@ def depth(self): @validate( Evaluate(IterableOf, Number(0, 1), Attr("depth"), Attr("depth")) ) - def set_alpha(self, alpha_list): + def set_alphas(self, alpha_list): """Set the alpha value for the color blending layers. Parameters @@ -322,18 +323,32 @@ def set_alpha(self, alpha_list): alpha_list : {0} The alpha values. """ - for alpha, layer in zip(alpha_list, self.layer_list()): + for alpha, layer in zip(alpha_list, self.layers()): layer.set_alpha(alpha) - def layer_list(self): - """Return a list of Layer objects for this color blending. + @validate( + NoneOr(Evaluate(IterableOf, Evaluate(Number, 0, Attr("depth"), Number.INCLUDE_MIN, step=1))) + ) + def layers(self, layer_ids=None): + """Return all or selected Layer objects for this color blending. + + When no layer IDs are supplied, all layers are returned. When layer + IDs are supplied, the layers are returned in the requested order. + Duplicate layer IDs are preserved. + + Parameters + ---------- + layer_ids : {0} + The layer IDs to return. By default, all layers are returned. Returns ------- list of :obj:`carta.color_blending.Layer` - A list of Layer objects. + The requested layers. """ - return Layer.from_list(self, list(range(self.depth))) + if layer_ids is None: + layer_ids = range(self.depth) + return Layer.from_list(self, layer_ids) @validate(InstanceOf(Image)) def add_layer(self, image): @@ -358,7 +373,7 @@ def delete_layer(self, layer_index): layer, the color blending is closed. """ if layer_index == 0: - layers = self.layer_list() + layers = self.layers() if len(layers) == 1: self.close() return diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 8ea84c0..d17ba71 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -318,7 +318,7 @@ Manipulate properties of the color blending object and the underlying layers: .. code-block:: python # Get layer objects - layer1, layer2, layer3 = cb.layer_list() + layer1, layer2, layer3 = cb.layers() # Set colormap for the images in individual layers layer1.set_colormap(Colormap.REDS) @@ -332,7 +332,7 @@ Manipulate properties of the color blending object and the underlying layers: cb.set_colormap_set(ColormapSet.RGB) # Print the current alpha values of all layers - print(cb.alpha) + print(cb.alphas) # Set alpha for individual layers layer1.set_alpha(0.7) @@ -340,7 +340,7 @@ Manipulate properties of the color blending object and the underlying layers: layer3.set_alpha(0.9) # Or set alpha for all layers at once - cb.set_alpha([0.7, 0.8, 0.9]) + cb.set_alphas([0.7, 0.8, 0.9]) # Replace the image in a layer # For layer1, this also makes the new image the spatial reference. @@ -354,7 +354,7 @@ Manipulate properties of the color blending object and the underlying layers: cb.add_layer(img2) # Layer objects can delete themselves from the color blending - layer1, layer2, layer3 = cb.layer_list() + layer1, layer2, layer3 = cb.layers() layer3.delete() # Deleting the base layer promotes the next layer to the spatial diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index 470560b..c0e57cb 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -317,7 +317,7 @@ def test_color_blending_view_index_raises_when_missing( def test_color_blending_alpha(color_blending, cb_get_value): - color_blending.alpha + color_blending.alphas cb_get_value.assert_called_with("alpha") @@ -348,18 +348,51 @@ def test_color_blending_make_active_does_not_read_view_index( assert call.args != ("imageViewConfigStore.imageListSummary",) -def test_color_blending_layer_list_derived(session, mocker): +def test_color_blending_layers_derived(session, mocker): cb = ColorBlending(session, 3) # Simulate two layers from the frontend's computed frames array length. gv = mocker.patch.object(cb, "get_value") gv.return_value = 2 - layers = cb.layer_list() + layers = cb.layers() assert [ly.layer_id for ly in layers] == [0, 1] gv.assert_called_once_with("frames.length") +def test_color_blending_layers_accepts_ids_in_order_with_duplicates( + color_blending, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + + layers = color_blending.layers([2, 0, 2]) + + assert [layer.layer_id for layer in layers] == [2, 0, 2] + + +@pytest.mark.parametrize("layer_ids", [[-1], [3], [1.5], ["1"]]) +def test_color_blending_layers_rejects_invalid_ids( + color_blending, mocker, layer_ids +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + from_list = mocker.patch.object(Layer, "from_list") + + with pytest.raises(CartaValidationFailed): + color_blending.layers(layer_ids) + + from_list.assert_not_called() + + def test_color_blending_add_layer(color_blending, cb_call_action, image): color_blending.add_layer(image) cb_call_action.assert_called_with("addSelectedFrame", image._frame) @@ -398,7 +431,7 @@ def test_color_blending_delete_base_layer_promotes_next_layer( return_value=2, ) layers = [Layer(color_blending, 0), Layer(color_blending, 1)] - mocker.patch.object(color_blending, "layer_list", return_value=layers) + mocker.patch.object(color_blending, "layers", return_value=layers) mocker.patch.object( Layer, "image_id", new_callable=mocker.PropertyMock, return_value=42 ) @@ -421,7 +454,7 @@ def test_color_blending_delete_only_base_layer_closes_color_blending( return_value=1, ) mocker.patch.object( - color_blending, "layer_list", return_value=[Layer(color_blending, 0)] + color_blending, "layers", return_value=[Layer(color_blending, 0)] ) close = mocker.patch.object(color_blending, "close") @@ -558,7 +591,7 @@ def test_color_blending_set_colormap_set(color_blending, cb_call_action): cb_call_action.assert_called_with("applyColormapSet", CMS.RAINBOW) -def test_color_blending_set_alpha_valid(color_blending, mocker): +def test_color_blending_set_alphas_valid(color_blending, mocker): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) mocker.patch.object( @@ -567,15 +600,15 @@ def test_color_blending_set_alpha_valid(color_blending, mocker): new_callable=mocker.PropertyMock, return_value=2, ) - mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + mocker.patch.object(ColorBlending, "layers", return_value=[ly1, ly2]) - color_blending.set_alpha([0.2, 0.8]) + color_blending.set_alphas([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_color_blending_set_alpha_invalid(color_blending, vals, mocker): +def test_color_blending_set_alphas_invalid(color_blending, vals, mocker): mocker.patch.object( ColorBlending, "depth", @@ -584,11 +617,11 @@ def test_color_blending_set_alpha_invalid(color_blending, vals, mocker): ) with pytest.raises(CartaValidationFailed): - color_blending.set_alpha(vals) + color_blending.set_alphas(vals) @pytest.mark.parametrize("vals", [[0.5], [0.1, 0.2, 0.3]]) -def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): +def test_color_blending_set_alphas_length_mismatch(color_blending, mocker, vals): ly1 = mocker.create_autospec(Layer(color_blending, 1), instance=True) ly2 = mocker.create_autospec(Layer(color_blending, 2), instance=True) mocker.patch.object( @@ -597,10 +630,10 @@ def test_color_blending_set_alpha_length_mismatch(color_blending, mocker, vals): new_callable=mocker.PropertyMock, return_value=2, ) - mocker.patch.object(ColorBlending, "layer_list", return_value=[ly1, ly2]) + mocker.patch.object(ColorBlending, "layers", return_value=[ly1, ly2]) with pytest.raises(CartaValidationFailed): - color_blending.set_alpha(vals) + color_blending.set_alphas(vals) @pytest.mark.parametrize( From 864312568a1b4bab3254582bda2fc09abc618a2b Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 22:08:13 +0800 Subject: [PATCH 91/95] Add validation to ColorBlending.set_layer_image to prevent duplicate images and include corresponding test coverage --- carta/color_blending.py | 19 ++++++++++++++++++- tests/test_color_blending.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/carta/color_blending.py b/carta/color_blending.py index 0357c20..5bd1106 100644 --- a/carta/color_blending.py +++ b/carta/color_blending.py @@ -3,7 +3,12 @@ from .constants import Colormap, ColormapSet, ImageType, SpatialAxis from .image import Image from .view import View -from .util import BasePathMixin, CartaScriptingException, Macro +from .util import ( + BasePathMixin, + CartaScriptingException, + CartaValidationFailed, + Macro, +) from .validation import ( Boolean, Constant, @@ -397,7 +402,19 @@ def set_layer_image(self, layer_index, image): specified secondary layer is replaced. image : {1} The image to set. + + Raises + ------ + CartaValidationFailed + If ``image`` is already present in this color blending. """ + image_id = image.image_id + if any(layer.image_id == image_id for layer in self.layers()): + raise CartaValidationFailed( + f"Image with image_id={image_id} is already in the " + "color blending layers." + ) + if layer_index == 0: image.set_spatial_matching(True) image.make_spatial_reference() diff --git a/tests/test_color_blending.py b/tests/test_color_blending.py index c0e57cb..69ab537 100644 --- a/tests/test_color_blending.py +++ b/tests/test_color_blending.py @@ -491,6 +491,7 @@ def test_color_blending_set_layer_image( new_callable=mocker.PropertyMock, return_value=6, ) + mocker.patch.object(color_blending, "layers", return_value=[]) color_blending.set_layer_image(idx, image) cb_call_action.assert_called_with( "setSelectedFrame", expected_param, image._frame @@ -514,6 +515,34 @@ def test_color_blending_set_layer_image_rejects_out_of_range( cb_call_action.assert_not_called() +@pytest.mark.parametrize("idx", [0, 2]) +def test_color_blending_set_layer_image_rejects_existing_image( + color_blending, cb_call_action, image, idx, mocker +): + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + return_value=3, + ) + mocker.patch.object(color_blending, "layers", return_value=[Layer(color_blending, 1)]) + mocker.patch.object( + Layer, + "image_id", + new_callable=mocker.PropertyMock, + return_value=image.image_id, + ) + set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") + make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") + + with pytest.raises(CartaValidationFailed, match="already"): + color_blending.set_layer_image(idx, image) + + cb_call_action.assert_not_called() + set_spatial_matching.assert_not_called() + make_spatial_reference.assert_not_called() + + def test_color_blending_set_base_layer_image( color_blending, cb_call_action, image, mocker ): @@ -523,6 +552,7 @@ def test_color_blending_set_base_layer_image( new_callable=mocker.PropertyMock, return_value=1, ) + mocker.patch.object(color_blending, "layers", return_value=[]) set_spatial_matching = mocker.patch.object(image, "set_spatial_matching") make_spatial_reference = mocker.patch.object(image, "make_spatial_reference") From eac811932a67dd0a46ab9ba8165b4cef34a5c859 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Thu, 13 Aug 2026 22:44:27 +0800 Subject: [PATCH 92/95] Add optional images parameter to Session.create_color_blending to allow explicit image selection and layer ordering, with validation for duplicates and session membership --- carta/session.py | 70 +++++++++++++++++++++++++++++------ docs/source/quickstart.rst | 5 +++ tests/test_session.py | 75 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 11 deletions(-) diff --git a/carta/session.py b/carta/session.py index 4f2679d..285f3cf 100644 --- a/carta/session.py +++ b/carta/session.py @@ -16,7 +16,7 @@ from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt -from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union +from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, InstanceOf, MapOf, Union from .wcs_overlay import SessionWCSOverlay from .raster import SessionRaster @@ -812,9 +812,30 @@ def active_view(self): # COLOR BLENDING - def create_color_blending(self): - """Create a new color blending from the current spatial reference - and its currently spatially matched images. + @validate(NoneOr(IterableOf(InstanceOf(Image), min_size=1))) + def create_color_blending(self, images=None): + """Create a color blending from open images. + + There are two ways to choose the images for the new color blending: + + * If ``images`` is provided, it must contain one or more + :obj:`carta.image.Image` objects from this session. The first image + becomes the base layer and spatial reference. The remaining images + become secondary layers in the same order as the input. The result + contains exactly these images; other open images are left open but + are not included. + * If ``images`` is omitted, the current spatial reference and all + currently spatially matched images are used. + + This method creates a color blending from images that are already + open. It does not open files or close other open images. + + Parameters + ---------- + images : {0} + An iterable of open images to combine, in the desired layer order. + The first image is used as the base layer. By default, use the + current spatial reference and spatially matched images. Returns ------- @@ -824,12 +845,31 @@ def create_color_blending(self): Raises ------ CartaActionFailed - If no images are open or the frontend could not create the - color blending image. + If no images are open, an image is no longer open, or the + frontend cannot create or update the color blending. + CartaValidationFailed + If ``images`` is empty, contains duplicates, or contains an image + from another session. """ - image_count = self.get_value("frames.length") - if image_count <= 0: - raise CartaActionFailed("No images are open.") + if images is not None: + if any(image.session is not self for image in images): + raise CartaValidationFailed( + "images must belong to the current session." + ) + + image_ids = [image.image_id for image in images] + if len(set(image_ids)) != len(image_ids): + raise CartaValidationFailed( + "images must not contain duplicate images." + ) + + images[0].make_spatial_reference() + for image in images[1:]: + image.set_spatial_matching(True) + else: + image_count = self.get_value("frames.length") + if image_count <= 0: + raise CartaActionFailed("No images are open.") color_blending_id = self.call_action( "imageViewConfigStore.createColorBlending", @@ -837,8 +877,16 @@ def create_color_blending(self): ) cb = ColorBlending(self, color_blending_id) - layer_count = cb.get_value("frames.length") - if layer_count <= 3: + if images is not None: + # The frontend action includes every currently spatially matched + # image, so normalize the created blending to the requested + # sequence before applying the final colormap set. + for layer_index in range(cb.depth - 1, 0, -1): + cb.delete_layer(layer_index) + for image in images[1:]: + cb.add_layer(image) + + if cb.depth <= 3: cb.set_colormap_set(ColormapSet.RGB) else: cb.set_colormap_set(ColormapSet.RAINBOW) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index d17ba71..253e242 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -307,6 +307,11 @@ The session object provides two convenience methods which create a color blendin img2.set_spatial_matching(True) cb = session.create_color_blending() + # Or select already-open images directly in the requested layer order. + cb = session.create_color_blending( + images=[img0, img1, img2] + ) + .. note:: ``session.open_as_color_blending(files)`` always closes any currently open images before opening ``files``. It then makes the first opened diff --git a/tests/test_session.py b/tests/test_session.py index 401c408..dc9fe85 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,6 +4,7 @@ from carta.image import Image from carta.color_blending import ColorBlending +from carta.session import Session from carta.util import CartaActionFailed, CartaBadResponse, CartaValidationFailed, Macro, Point as Pt from carta.constants import ColormapSet, ComplexComponent as CC, ImageType, Polarization as Pol @@ -545,6 +546,80 @@ def test_create_color_blending_calls_frontend_create_action(session, mocker, ope assert result.color_blending_id == 123 +def test_create_color_blending_with_images_sets_reference_and_matching( + session, mocker +): + images = [Image(session, 10), Image(session, 20), Image(session, 30)] + for image in images: + mocker.patch.object(image, "make_spatial_reference") + mocker.patch.object(image, "set_spatial_matching") + mocker.patch.object(session, "call_action", return_value=123) + mocker.patch.object( + ColorBlending, + "depth", + new_callable=mocker.PropertyMock, + side_effect=[4, 3], + ) + delete_layer = mocker.patch.object(ColorBlending, "delete_layer") + add_layer = mocker.patch.object(ColorBlending, "add_layer") + mock_set_colormap = mocker.patch.object(ColorBlending, "set_colormap_set") + + result = session.create_color_blending(images=images) + + images[0].make_spatial_reference.assert_called_once_with() + images[0].set_spatial_matching.assert_not_called() + images[1].set_spatial_matching.assert_called_once_with(True) + images[2].set_spatial_matching.assert_called_once_with(True) + assert delete_layer.call_args_list == [ + mocker.call(3), + mocker.call(2), + mocker.call(1), + ] + assert add_layer.call_args_list == [ + mocker.call(images[1]), + mocker.call(images[2]), + ] + mock_set_colormap.assert_called_once_with(ColormapSet.RGB) + assert isinstance(result, ColorBlending) + + +@pytest.mark.parametrize("images", [[], [object()]]) +def test_create_color_blending_rejects_invalid_images( + session, mocker, images +): + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + +def test_create_color_blending_rejects_duplicate_images(session, mocker): + images = [Image(session, 10), Image(session, 20), Image(session, 10)] + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises( + CartaValidationFailed, + match="must not contain duplicate images", + ): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + +def test_create_color_blending_rejects_image_from_another_session( + session, mocker +): + images = [Image(session, 10), Image(Session(1, None), 20)] + call_action = mocker.patch.object(session, "call_action") + + with pytest.raises(CartaValidationFailed, match="current session"): + session.create_color_blending(images=images) + + call_action.assert_not_called() + + def test_create_color_blending_raises_when_no_images_are_open(session, mocker): get_value = mocker.patch.object(session, "get_value", return_value=0) call_action = mocker.patch.object(session, "call_action") From 665cef5c204e56579f067b53f1937bff6f6b9e36 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 14 Aug 2026 16:00:50 +0800 Subject: [PATCH 93/95] Refactor views, images, and color_blendings methods to use single frontend state queries, add validation for out-of-range and closed view indices, and improve error handling for unsupported view types --- carta/session.py | 105 +++++++++++++++++++---------- tests/test_session.py | 152 ++++++++++++++++++++++++++++-------------- 2 files changed, 170 insertions(+), 87 deletions(-) diff --git a/carta/session.py b/carta/session.py index 285f3cf..a091b1c 100644 --- a/carta/session.py +++ b/carta/session.py @@ -12,7 +12,7 @@ from .image import Image from .view import View from .color_blending import ColorBlending -from .constants import PanelMode, GridMode, ComplexComponent, Polarization, ColormapSet +from .constants import PanelMode, GridMode, ComplexComponent, ImageType, Polarization, ColormapSet from .backend import Backend from .protocol import Protocol from .util import Macro, split_action_path, CartaActionFailed, CartaBadResponse, CartaBadID, CartaBadSession, CartaBadUrl, CartaScriptingException, CartaValidationFailed, cached, Point as Pt @@ -574,44 +574,54 @@ def views(self, view_indices=None): indices are supplied, the views at those positions are returned in the requested order. + Unsupported view types are skipped when no indices are supplied. + Returns ------- list of :obj:`carta.view.View` The requested heterogeneous views open in this session. + + Raises + ------ + IndexError + If any requested view index is out of range. + NotImplementedError + If an explicitly requested view type is unsupported. + CartaValidationFailed + If ``view_indices`` contains an invalid value. """ + summary = self.get_value("imageViewConfigStore.imageListSummary") + if view_indices is not None: - return [ - self.view_by_id(view_index=view_index) + out_of_range = [ + view_index for view_index in view_indices + if view_index >= len(summary) ] + if out_of_range: + raise IndexError( + f"view_indices {out_of_range!r} are out of range for " + f"views of length {len(summary)}." + ) + indexed_entries = [] + for view_index in view_indices: + indexed_entries.append((view_index, summary[view_index])) + skip_unsupported = False + else: + indexed_entries = enumerate(summary) + skip_unsupported = True - summary = self.get_value("imageViewConfigStore.imageListSummary") result = [] - for index, entry in enumerate(summary): + for index, entry in indexed_entries: try: - result.append( - View.view_class(entry["type"])(self, entry["id"]) - ) - except (CartaValidationFailed, NotImplementedError) as e: - print( - f"Skipping unsupported view entry at index {index}: " - f"{entry!r}: {e}" - ) + result.append(View.view_class(entry["type"])(self, entry["id"])) + except (CartaValidationFailed, NotImplementedError): + if not skip_unsupported: + raise + view_type = ImageType(entry["type"]) + print(f"Skipping unsupported {view_type.name} view at index {index}.") return result - def _view_list(self, path, view_class, return_path): - count = self.get_value(f"{path}.length") - return [ - view_class( - self, - self.get_value( - f"{path}[{index}]", - return_path=return_path, - ), - ) - for index in range(count) - ] - @validate(NoneOr(IterableOf(Number.ID))) def images(self, image_ids=None): """Return images from the session. @@ -630,8 +640,17 @@ def images(self, image_ids=None): The requested images. """ if image_ids is None: - return self._view_list("frames", Image, "frameInfo.fileId") - return [self.view_by_id(image_id=image_id) for image_id in image_ids] + return [ + Image(self, entry["value"]) for entry in self.get_value("frameNames") + ] + + frame_ids = {entry["value"] for entry in self.get_value("frameNames")} + images = [] + for image_id in image_ids: + if image_id not in frame_ids: + raise RuntimeError(f"No image with image_id={image_id} is open.") + images.append(Image(self, image_id)) + return images @validate(NoneOr(IterableOf(Number.ID))) def color_blendings(self, color_blending_ids=None): @@ -652,16 +671,28 @@ def color_blendings(self, color_blending_ids=None): list of :obj:`carta.color_blending.ColorBlending` The requested color blending images. """ + summary = self.get_value("imageViewConfigStore.imageListSummary") if color_blending_ids is None: - return self._view_list( - "imageViewConfigStore.colorBlendingImages", - ColorBlending, - "id", - ) - return [ - self.view_by_id(color_blending_id=color_blending_id) - for color_blending_id in color_blending_ids - ] + return [ + ColorBlending(self, entry["id"]) + for entry in summary + if entry["type"] == ImageType.COLOR_BLENDING + ] + + color_blending_ids_in_summary = { + entry["id"] + for entry in summary + if entry["type"] == ImageType.COLOR_BLENDING + } + color_blendings = [] + for color_blending_id in color_blending_ids: + if color_blending_id not in color_blending_ids_in_summary: + raise RuntimeError( + f"No color blending with " + f"color_blending_id={color_blending_id} is open." + ) + color_blendings.append(ColorBlending(self, color_blending_id)) + return color_blendings def _find_view_index(self, view_type, stable_id): """Return the view index of an item identified by a stable id. diff --git a/tests/test_session.py b/tests/test_session.py index dc9fe85..14ba6af 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -142,17 +142,14 @@ def test_views_heterogeneous(session, get_value): def test_views_skips_unsupported_view_type(session, get_value, capsys): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, - {"type": 99, "id": 11}, + {"type": ImageType.PV_PREVIEW, "id": 11}, {"type": ImageType.FRAME, "id": 20}, ] views = session.views() assert [view.image_id for view in views] == [10, 20] - assert ( - "Skipping unsupported view entry at index 1" - in capsys.readouterr().out - ) + assert capsys.readouterr().out == "Skipping unsupported PV_PREVIEW view at index 1.\n" def test_views_empty(session, get_value): @@ -163,19 +160,49 @@ def test_views_empty(session, get_value): ) -def test_views_uses_explicit_indices(session, mocker): - view_by_id = mocker.patch.object(session, "view_by_id") - view_by_id.side_effect = ["view-2", "view-0", "view-2-again"] +def test_views_uses_one_summary_for_explicit_indices(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.FRAME, "id": 20}, + ] views = session.views(view_indices=[2, 0, 2]) - assert views == ["view-2", "view-0", "view-2-again"] - assert view_by_id.call_args_list == [ - call(view_index=2), - call(view_index=0), - call(view_index=2), + assert [ + (type(view), view.image_id if isinstance(view, Image) else view.color_blending_id) + for view in views + ] == [(Image, 20), (Image, 10), (Image, 20)] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_views_explicit_indices_reject_out_of_range(session, get_value): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, ] + with pytest.raises(IndexError, match=r"view_indices \[1, 2\]"): + session.views(view_indices=[1, 2]) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_views_explicit_indices_raise_for_unsupported_type(session, get_value): + get_value.return_value = [ + {"type": ImageType.PV_PREVIEW, "id": -2} + ] + + with pytest.raises(NotImplementedError): + session.views(view_indices=[0]) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + @pytest.mark.parametrize("view_indices", [[-1], [1.5], ["1"]]) def test_views_rejects_invalid_indices(session, get_value, view_indices): @@ -185,34 +212,49 @@ def test_views_rejects_invalid_indices(session, get_value, view_indices): get_value.assert_not_called() -def test_images_uses_frontend_image_array(session, get_value): - get_value.side_effect = [2, 10, 20] +def test_images_uses_frontend_frame_names(session, get_value): + get_value.return_value = [ + {"value": 10, "label": "Image 10"}, + {"value": 20, "label": "Image 20"}, + ] images = session.images() assert [image.image_id for image in images] == [10, 20] - assert get_value.call_args_list == [ - call("frames.length"), - call("frames[0]", return_path="frameInfo.fileId"), - call("frames[1]", return_path="frameInfo.fileId"), + get_value.assert_called_once_with( + "frameNames" + ) + + +def test_images_uses_frame_names_for_explicit_ids(session, get_value): + get_value.return_value = [ + {"value": 10, "label": "Image 10"}, + {"value": 20, "label": "Image 20"}, ] + images = session.images(image_ids=[20, 10, 20]) -def test_images_uses_image_map_for_explicit_ids(session, mocker): - view_by_id = mocker.patch.object(session, "view_by_id") - view_by_id.side_effect = [object(), object()] + assert [image.image_id for image in images] == [20, 10, 20] + get_value.assert_called_once_with("frameNames") - images = session.images(image_ids=[10, 20]) - assert len(images) == 2 - assert view_by_id.call_args_list == [ - call(image_id=10), - call(image_id=20), - ] +def test_images_rejects_closed_explicit_id(session, get_value): + get_value.return_value = [{"value": 10, "label": "Image 10"}] + with pytest.raises(RuntimeError, match="image_id=20"): + session.images(image_ids=[20]) -def test_color_blendings_uses_color_blending_list(session, get_value): - get_value.side_effect = [2, 3, 7] + get_value.assert_called_once_with("frameNames") + + +def test_color_blendings_uses_frontend_image_list_summary( + session, get_value +): + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ] color_blendings = session.color_blendings() @@ -220,33 +262,43 @@ def test_color_blendings_uses_color_blending_list(session, get_value): color_blending.color_blending_id for color_blending in color_blendings ] == [3, 7] - assert get_value.call_args_list == [ - call("imageViewConfigStore.colorBlendingImages.length"), - call( - "imageViewConfigStore.colorBlendingImages[0]", - return_path="id", - ), - call( - "imageViewConfigStore.colorBlendingImages[1]", - return_path="id", - ), - ] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) -def test_color_blendings_uses_color_blending_map_for_explicit_ids( - session, mocker +def test_color_blendings_uses_image_list_summary_for_explicit_ids( + session, get_value ): - view_by_id = mocker.patch.object(session, "view_by_id") - view_by_id.side_effect = [object(), object()] + get_value.return_value = [ + {"type": ImageType.FRAME, "id": 10}, + {"type": ImageType.COLOR_BLENDING, "id": 3}, + {"type": ImageType.COLOR_BLENDING, "id": 7}, + ] - color_blendings = session.color_blendings([3, 7]) + color_blendings = session.color_blendings([7, 3, 7]) - assert len(color_blendings) == 2 - assert view_by_id.call_args_list == [ - call(color_blending_id=3), - call(color_blending_id=7), + assert [ + color_blending.color_blending_id + for color_blending in color_blendings + ] == [7, 3, 7] + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + + +def test_color_blendings_rejects_closed_explicit_id(session, get_value): + get_value.return_value = [ + {"type": ImageType.COLOR_BLENDING, "id": 3} ] + with pytest.raises(RuntimeError, match="color_blending_id=7"): + session.color_blendings([7]) + + get_value.assert_called_once_with( + "imageViewConfigStore.imageListSummary" + ) + def test_find_view_index_single_round_trip(session, call_action): call_action.side_effect = [2, 1] From c33caa5d570d3bd56e841ee7a1b34d7be53a81fd Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 14 Aug 2026 16:06:20 +0800 Subject: [PATCH 94/95] Remove unnecessary docstring modifications --- carta/image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/image.py b/carta/image.py index bd2c2fa..6c7fade 100644 --- a/carta/image.py +++ b/carta/image.py @@ -1,4 +1,4 @@ -"""This module contains the image classes representing images open in the session. +"""This module contains an image class which represents a single image open in the session. Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ @@ -27,14 +27,14 @@ class Image(View, BasePathMixin): session : :obj:`carta.session.Session` The session object associated with this image. image_id : integer - The frontend image ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. + The ID identifying this image within the session. This is a unique number which is not reused, not the index of the image within the list of currently open images. Attributes ---------- session : :obj:`carta.session.Session` The session object associated with this image. image_id : integer - The frontend image ID identifying this image within the session. + The ID identifying this image within the session. raster : :obj:`carta.raster.Raster` Sub-object with functions related to the raster image. contours : :obj:`carta.contours.Contours` From ef07daedc26b74fa6e859b39e4d163784ac4a7d4 Mon Sep 17 00:00:00 2001 From: Zhen-Kai Gao Date: Fri, 14 Aug 2026 23:00:03 +0800 Subject: [PATCH 95/95] Refactor views, images, and color_blendings methods to use walrus operator for missing ID/index detection, simplify list comprehensions, and improve error messages to list all invalid values instead of just the first one --- carta/session.py | 152 ++++++++++++------------------------------ tests/test_session.py | 27 +++++--- 2 files changed, 61 insertions(+), 118 deletions(-) diff --git a/carta/session.py b/carta/session.py index a091b1c..615d52b 100644 --- a/carta/session.py +++ b/carta/session.py @@ -242,10 +242,7 @@ def __repr__(self): except (AttributeError, CartaScriptingException): return f"Session(session_id={self.session_id}, uri={uri!r})" - return ( - f"Session(session_id={self.session_id}, uri={uri!r}, " - f"carta_version={version!r})" - ) + return f"Session(session_id={self.session_id}, uri={uri!r}, carta_version={version!r})" # METADATA @@ -591,36 +588,28 @@ def views(self, view_indices=None): If ``view_indices`` contains an invalid value. """ summary = self.get_value("imageViewConfigStore.imageListSummary") + remote_indices = range(len(summary)) - if view_indices is not None: - out_of_range = [ - view_index - for view_index in view_indices - if view_index >= len(summary) - ] - if out_of_range: - raise IndexError( - f"view_indices {out_of_range!r} are out of range for " - f"views of length {len(summary)}." - ) - indexed_entries = [] - for view_index in view_indices: - indexed_entries.append((view_index, summary[view_index])) - skip_unsupported = False - else: - indexed_entries = enumerate(summary) + if view_indices is None: + view_indices = remote_indices skip_unsupported = True + else: + missing_indices = [i for i in view_indices if i not in remote_indices] + if missing_indices: + raise IndexError(f"No views with indices {missing_indices} are open.") + skip_unsupported = False - result = [] - for index, entry in indexed_entries: + views = [] + for index in view_indices: + entry = summary[index] try: - result.append(View.view_class(entry["type"])(self, entry["id"])) + views.append(View.view_class(entry["type"])(self, entry["id"])) except (CartaValidationFailed, NotImplementedError): if not skip_unsupported: raise view_type = ImageType(entry["type"]) print(f"Skipping unsupported {view_type.name} view at index {index}.") - return result + return views @validate(NoneOr(IterableOf(Number.ID))) def images(self, image_ids=None): @@ -639,18 +628,13 @@ def images(self, image_ids=None): list of :obj:`carta.image.Image` The requested images. """ - if image_ids is None: - return [ - Image(self, entry["value"]) for entry in self.get_value("frameNames") - ] + remote_ids = [i["value"] for i in self.get_value("frameNames")] - frame_ids = {entry["value"] for entry in self.get_value("frameNames")} - images = [] - for image_id in image_ids: - if image_id not in frame_ids: - raise RuntimeError(f"No image with image_id={image_id} is open.") - images.append(Image(self, image_id)) - return images + if image_ids is None: + image_ids = remote_ids + elif missing_ids := [i for i in image_ids if i not in remote_ids]: + raise RuntimeError(f"No images with image_ids {missing_ids} are open.") + return [Image(self, i) for i in image_ids] @validate(NoneOr(IterableOf(Number.ID))) def color_blendings(self, color_blending_ids=None): @@ -672,27 +656,13 @@ def color_blendings(self, color_blending_ids=None): The requested color blending images. """ summary = self.get_value("imageViewConfigStore.imageListSummary") + remote_ids = [i["id"] for i in summary if i["type"] == ImageType.COLOR_BLENDING] + if color_blending_ids is None: - return [ - ColorBlending(self, entry["id"]) - for entry in summary - if entry["type"] == ImageType.COLOR_BLENDING - ] - - color_blending_ids_in_summary = { - entry["id"] - for entry in summary - if entry["type"] == ImageType.COLOR_BLENDING - } - color_blendings = [] - for color_blending_id in color_blending_ids: - if color_blending_id not in color_blending_ids_in_summary: - raise RuntimeError( - f"No color blending with " - f"color_blending_id={color_blending_id} is open." - ) - color_blendings.append(ColorBlending(self, color_blending_id)) - return color_blendings + color_blending_ids = remote_ids + elif missing_ids := [i for i in color_blending_ids if i not in remote_ids]: + raise RuntimeError(f"No color blendings with color_blending_ids {missing_ids} are open.") + return [ColorBlending(self, i) for i in color_blending_ids] def _find_view_index(self, view_type, stable_id): """Return the view index of an item identified by a stable id. @@ -722,16 +692,11 @@ def _find_view_index(self, view_type, stable_id): response_expected=True, ) if view_index == -1: - raise RuntimeError( - f"Could not find a view of type {view_type!r} with id " - f"{stable_id} in the views." - ) + raise RuntimeError(f"Could not find a view of type {view_type!r} with id {stable_id} in the views.") return view_index @validate(NoneOr(Number.ID), NoneOr(Number.ID), NoneOr(Number.ID)) - def view_by_id( - self, *, view_index=None, image_id=None, color_blending_id=None - ): + def view_by_id(self, *, view_index=None, image_id=None, color_blending_id=None): """Return the view identified by exactly one supported identifier. Parameters @@ -765,16 +730,9 @@ def view_by_id( If no matching entry exists for the given ``image_id`` or ``color_blending_id``. There is no cross-type fallback. """ - provided = { - "view_index": view_index, - "image_id": image_id, - "color_blending_id": color_blending_id, - } - provided_values = { - key: value - for key, value in provided.items() - if value is not None - } + provided = {"view_index": view_index, "image_id": image_id, "color_blending_id": color_blending_id} + provided_values = {k: v for k, v in provided.items() if v is not None} + if len(provided_values) != 1: raise ValueError( "view_by_id requires exactly one of the keyword arguments " @@ -784,40 +742,25 @@ def view_by_id( if view_index is not None: try: - entry = self.get_value( - "imageViewConfigStore.imageListSummary" - f"[{view_index}]" - ) + entry = self.get_value(f"imageViewConfigStore.imageListSummary[{view_index}]") except (CartaActionFailed, CartaBadResponse) as e: - raise IndexError( - f"view_index {view_index} is out of range for the views." - ) from e + raise IndexError(f"view_index {view_index} is out of range for the views.") from e return View.view_class(entry["type"])(self, entry["id"]) if image_id is not None: try: - resolved_image_id = self.get_value( - f"frameMap[{image_id}]", - return_path="frameInfo.fileId", - ) + resolved_image_id = self.get_value(f"frameMap[{image_id}]", return_path="frameInfo.fileId") except (CartaActionFailed, CartaBadResponse) as e: - raise RuntimeError( - f"No image with image_id={image_id} is open." - ) from e + raise RuntimeError(f"No image with image_id={image_id} is open.") from e return Image(self, resolved_image_id) # color_blending_id is not None try: resolved_color_blending_id = self.get_value( - f"imageViewConfigStore.colorBlendingImageMap" - f"[{color_blending_id}]", - return_path="id", + f"imageViewConfigStore.colorBlendingImageMap[{color_blending_id}]", return_path="id" ) except (CartaActionFailed, CartaBadResponse) as e: - raise RuntimeError( - f"No color blending with color_blending_id={color_blending_id} " - "is open." - ) from e + raise RuntimeError(f"No color blending with color_blending_id={color_blending_id} is open.") from e return ColorBlending(self, resolved_color_blending_id) def active_view(self): @@ -837,9 +780,7 @@ def active_view(self): the Python side. """ active = self.get_value("activeImage") - return View.view_class(active["type"])( - self, active["store"]["id"] - ) + return View.view_class(active["type"])(self, active["store"]["id"]) # COLOR BLENDING @@ -883,16 +824,12 @@ def create_color_blending(self, images=None): from another session. """ if images is not None: - if any(image.session is not self for image in images): - raise CartaValidationFailed( - "images must belong to the current session." - ) + if any(i.session is not self for i in images): + raise CartaValidationFailed("images must belong to the current session.") - image_ids = [image.image_id for image in images] + image_ids = [i.image_id for i in images] if len(set(image_ids)) != len(image_ids): - raise CartaValidationFailed( - "images must not contain duplicate images." - ) + raise CartaValidationFailed("images must not contain duplicate images.") images[0].make_spatial_reference() for image in images[1:]: @@ -902,10 +839,7 @@ def create_color_blending(self, images=None): if image_count <= 0: raise CartaActionFailed("No images are open.") - color_blending_id = self.call_action( - "imageViewConfigStore.createColorBlending", - return_path="id", - ) + color_blending_id = self.call_action("imageViewConfigStore.createColorBlending", return_path="id") cb = ColorBlending(self, color_blending_id) if images is not None: diff --git a/tests/test_session.py b/tests/test_session.py index 14ba6af..5ef1116 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -178,13 +178,15 @@ def test_views_uses_one_summary_for_explicit_indices(session, get_value): ) -def test_views_explicit_indices_reject_out_of_range(session, get_value): +def test_views_explicit_indices_list_all_out_of_range(session, get_value): get_value.return_value = [ {"type": ImageType.FRAME, "id": 10}, ] - with pytest.raises(IndexError, match=r"view_indices \[1, 2\]"): - session.views(view_indices=[1, 2]) + with pytest.raises(IndexError) as exc_info: + session.views(view_indices=[2, 0, 1]) + + assert str(exc_info.value) == "No views with indices [2, 1] are open." get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary" @@ -238,11 +240,13 @@ def test_images_uses_frame_names_for_explicit_ids(session, get_value): get_value.assert_called_once_with("frameNames") -def test_images_rejects_closed_explicit_id(session, get_value): +def test_images_list_all_closed_explicit_ids(session, get_value): get_value.return_value = [{"value": 10, "label": "Image 10"}] - with pytest.raises(RuntimeError, match="image_id=20"): - session.images(image_ids=[20]) + with pytest.raises(RuntimeError) as exc_info: + session.images(image_ids=[30, 10, 20]) + + assert str(exc_info.value) == "No images with image_ids [30, 20] are open." get_value.assert_called_once_with("frameNames") @@ -287,13 +291,18 @@ def test_color_blendings_uses_image_list_summary_for_explicit_ids( ) -def test_color_blendings_rejects_closed_explicit_id(session, get_value): +def test_color_blendings_list_all_closed_explicit_ids(session, get_value): get_value.return_value = [ {"type": ImageType.COLOR_BLENDING, "id": 3} ] - with pytest.raises(RuntimeError, match="color_blending_id=7"): - session.color_blendings([7]) + with pytest.raises(RuntimeError) as exc_info: + session.color_blendings([7, 3, 5]) + + assert ( + str(exc_info.value) + == "No color blendings with color_blending_ids [7, 5] are open." + ) get_value.assert_called_once_with( "imageViewConfigStore.imageListSummary"