diff --git a/VERSION.txt b/VERSION.txt index 9ee1f78..26aaba0 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.1.11 +1.2.0 diff --git a/carta/constants.py b/carta/constants.py index 7b56f56..7feb41e 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -142,6 +142,10 @@ class SmoothingMode(IntEnum): GAUSSIAN_BLUR = 2 +VectorOverlaySource = Enum('VectorOverlaySource', ('NONE', 'CURRENT', 'COMPUTED'), type=int, start=-1) +VectorOverlaySource.__doc__ = """Vector overlay source.""" + + class Auto(StrEnum): """Special value for parameters to be calculated automatically.""" AUTO = "Auto" diff --git a/carta/image.py b/carta/image.py index fa61ed9..d71037b 100644 --- a/carta/image.py +++ b/carta/image.py @@ -2,11 +2,13 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ + from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, SpatialAxis from .util import Macro, cached, BasePathMixin from .units import AngularSize, WorldCoordinate from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional from .metadata import parse_header +from .vector_overlay import VectorOverlay class Image(BasePathMixin): @@ -36,6 +38,9 @@ def __init__(self, session, image_id): self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) + # Sub-objects grouping related functions + self.vectors = VectorOverlay(self) + @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. diff --git a/carta/vector_overlay.py b/carta/vector_overlay.py new file mode 100644 index 0000000..dc30064 --- /dev/null +++ b/carta/vector_overlay.py @@ -0,0 +1,268 @@ +"""This module contains functionality for interacting with the vector overlay of an image. The class in this module should not be instantiated directly. When an image object is created, a vector overlay object is automatically created as a property.""" + +from .util import logger, Macro, BasePathMixin +from .constants import Colormap, VectorOverlaySource, Auto +from .validation import validate, Number, Color, Constant, Boolean, all_optional, Union + + +class VectorOverlay(BasePathMixin): + """Utility object for collecting image functions related to the vector overlay. + + Parameters + ---------- + image : :obj:`carta.image.Image` object + The image associated with this vector overlay. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image associated with this vector overlay. + session : :obj:`carta.session.Session` object + The session object associated with this vector overlay. + """ + + def __init__(self, image): + self.image = image + self.session = image.session + self._base_path = f"{image._base_path}.vectorOverlayConfig" + + @validate(*all_optional(Constant(VectorOverlaySource), Constant(VectorOverlaySource), Boolean(), Number(), Number(), Boolean(), Number(), Boolean(), Number(), Number())) + def configure(self, angular_source=None, intensity_source=None, pixel_averaging_enabled=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=None, threshold=None, debiasing=None, q_error=None, u_error=None): + """Configure vector overlay. + + All parameters are optional. For each option that is not provided, the value currently set in the frontend will be preserved. Initial frontend settings are noted below. + + We deduce some boolean options. For example, providing an explicit pixel averaging width with the **pixel_averaging** parameter will automatically enable pixel averaging unless **pixel_averaging_enabled** is also explicitly set to ``False``. To disable pixel averaging, explicitly set **pixel_averaging_enabled** to ``False``. + + Parameters + ---------- + angular_source : {0} + The angular source. This is initially set to computed PA if the image contains Stokes information, otherwise to the current image. + intensity_source : {1} + The intensity source. This is initially set to computed PI if the image contains Stokes information, otherwise to the current image. + pixel_averaging_enabled : {2} + Enable pixel averaging. This is initially enabled if the pixel averaging width is positive. + pixel_averaging : {3} + The pixel averaging width in pixels. The initial value can be configured in the frontend preferences (the default is ``4``). + fractional_intensity : {4} + Enable fractional polarization intensity. The initial value can be configured in the frontend preferences. By default this is disabled and the absolute polarization intensity is used. + threshold_enabled : {5} + Enable threshold. Initially the threshold is disabled. + threshold : {6} + The threshold in Jy/pixels. The initial value is zero. + debiasing : {7} + Enable debiasing. This is initially disabled. + q_error : {8} + The Stokes Q error in Jy/beam. Set both this and ``u_error`` to enable debiasing. Initially set to zero. + u_error : {9} + The Stokes U error in Jy/beam. Set both this and ``q_error`` to enable debiasing. Initially set to zero. + """ + + # Avoid doing a lot of needless work for a no-op + if any(name != "self" and arg is not None for name, arg in locals().items()): + if pixel_averaging is not None and pixel_averaging_enabled is None: + pixel_averaging_enabled = True + if threshold is not None and threshold_enabled is None: + threshold_enabled = True + if q_error is not None and u_error is not None and debiasing is None: + debiasing = True + + if (q_error is not None and u_error is None) or (q_error is None and u_error is not None): + debiasing = False + logger.warning("The Stokes Q error and Stokes U error must both be set to enable debiasing.") + + args = [] + + for value, attr_name in ( + (angular_source, "angularSource"), + (intensity_source, "intensitySource"), + (pixel_averaging_enabled, "pixelAveragingEnabled"), + (pixel_averaging, "pixelAveraging"), + (fractional_intensity, "fractionalIntensity"), + (threshold_enabled, "thresholdEnabled"), + (threshold, "threshold"), + (debiasing, "debiasing"), + (q_error, "qError"), + (u_error, "uError"), + ): + if value is None: + args.append(self.macro("", attr_name)) + else: + args.append(value) + + self.call_action("setVectorOverlayConfiguration", *args) + + @validate(*all_optional(Number(), Union(Number(), Constant(Auto)), Union(Number(), Constant(Auto)), Number(), Number(), Number())) + def set_style(self, thickness=None, intensity_min=None, intensity_max=None, length_min=None, length_max=None, rotation_offset=None): + """Set the styling (line thickness, intensity range, line length range, rotation offset) of vector overlay. + + Parameters + ---------- + thickness : {0} + The line thickness in pixels. The initial value is ``1``. + intensity_min : {1} + The minimum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. + intensity_max : {2} + The maximum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. + length_min : {3} + The minimum value of line length in pixels. The initial value is ``0``. + length_max : {4} + The maximum value of line length in pixels. The initial value is ``20``. + rotation_offset : {5} + The rotation offset in degrees. The initial value is ``0``. + """ + if thickness is not None: + self.call_action("setThickness", thickness) + + if intensity_min is not None or intensity_max is not None: + if intensity_min is None: + intensity_min = self.macro("", "intensityMin") + elif intensity_min is Auto.AUTO: + intensity_min = Macro.UNDEFINED + + if intensity_max is None: + intensity_max = self.macro("", "intensityMax") + elif intensity_max is Auto.AUTO: + intensity_max = Macro.UNDEFINED + + self.call_action("setIntensityRange", intensity_min, intensity_max) + + if length_min is not None and length_max is not None: + self.call_action("setLengthRange", length_min, length_max) + + if rotation_offset is not None: + self.call_action("setRotationOffset", rotation_offset) + + @validate(Color()) + def set_color(self, color): + """Set the vector overlay color. + + This automatically disables use of the vector overlay colormap. + + Parameters + ---------- + color : {0} + The color. The initial value is ``#238551`` (a shade of green). + """ + self.call_action("setColor", color) + self.call_action("setColormapEnabled", False) + + @validate(*all_optional(Constant(Colormap), Number(-1, 1), Number(0, 2))) + def set_colormap(self, colormap=None, bias=None, contrast=None): + """Set the vector overlay colormap and/or the colormap options. + + Parameters + ---------- + colormap : {0} + The colormap. The initial value is :obj:`carta.constants.Colormap.VIRIDIS`. If this parameter is set, the overlay colormap is automatically enabled. + bias : {1} + The colormap bias. The initial value is ``0``. + contrast : {2} + The colormap contrast. The initial value is ``1``. + """ + if colormap is not None: + self.call_action("setColormap", colormap) + self.call_action("setColormapEnabled", True) + if bias is not None: + self.call_action("setColormapBias", bias) + if contrast is not None: + self.call_action("setColormapContrast", contrast) + + def apply(self): + """Apply the vector overlay configuration.""" + self.image.call_action("applyVectorOverlay") + + @validate(*all_optional(*configure.VARGS, *set_style.VARGS, *set_color.VARGS, *set_colormap.VARGS)) + def plot(self, angular_source=None, intensity_source=None, pixel_averaging_enabled=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=None, threshold=None, debiasing=None, q_error=None, u_error=None, thickness=None, intensity_min=None, intensity_max=None, length_min=None, length_max=None, rotation_offset=None, color=None, colormap=None, bias=None, contrast=None): + """Set the vector overlay configuration, styling and color or colormap; and apply vector overlay; in a single step. + + If both a color and a colormap are provided, the colormap will be enabled. + + Parameters + ---------- + angular_source : {0} + The angular source. This is initially set to computed PA if the image contains Stokes information, otherwise to the current image. + intensity_source : {1} + The intensity source. This is initially set to computed PI if the image contains Stokes information, otherwise to the current image. + pixel_averaging_enabled : {2} + Enable pixel averaging. This is initially enabled if the pixel averaging width is positive. + pixel_averaging : {3} + The pixel averaging width in pixels. The initial value can be configured in the frontend preferences (the default is ``4``). + fractional_intensity : {4} + Enable fractional polarization intensity. The initial value can be configured in the frontend preferences. By default this is disabled and the absolute polarization intensity is used. + threshold_enabled : {5} + Enable threshold. Initially the threshold is disabled. + threshold : {6} + The threshold in Jy/pixels. The initial value is zero. + debiasing : {7} + Enable debiasing. This is initially disabled. + q_error : {8} + The Stokes Q error in Jy/beam. Set both this and ``u_error`` to enable debiasing. Initially set to zero. + u_error : {9} + The Stokes U error in Jy/beam. Set both this and ``q_error`` to enable debiasing. Initially set to zero. + thickness : {10} + The line thickness in pixels. The initial value is ``1``. + intensity_min : {11} + The minimum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. + intensity_max : {12} + The maximum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. + length_min : {13} + The minimum value of line length in pixels. The initial value is ``0``. + length_max : {14} + The maximum value of line length in pixels. The initial value is ``20``. + rotation_offset : {15} + The rotation offset in degrees. The initial value is ``0``. + color : {16} + The color. The initial value value is ``#238551`` (a shade of green). + colormap : {17} + The colormap. The initial value is :obj:`carta.constants.Colormap.VIRIDIS`. + bias : {18} + The colormap bias. The initial value is ``0``. + contrast : {19} + The colormap contrast. The initial value is ``1``. + """ + changes_made = False + + configure_args = (angular_source, intensity_source, pixel_averaging_enabled, pixel_averaging, fractional_intensity, threshold_enabled, threshold, debiasing, q_error, u_error) + if any(v is not None for v in configure_args): + self.configure(*configure_args) + changes_made = True + + set_style_args = (thickness, intensity_min, intensity_max, length_min, length_max, rotation_offset) + if any(v is not None for v in set_style_args): + self.set_style(*set_style_args) + changes_made = True + + if color is not None: + self.set_color(color) + changes_made = True + + if colormap is not None or bias is not None or contrast is not None: + self.set_colormap(colormap, bias, contrast) + changes_made = True + + if changes_made: + self.apply() + + def clear(self): + """Clear the vector overlay configuration.""" + self.image.call_action("clearVectorOverlay", True) + + @validate(Boolean()) + def set_visible(self, state): + """Set the vector overlay visibility. + + Parameters + ---------- + state : {0} + The desired visibility state. + """ + self.call_action("setVisible", state) + + def show(self): + """Show the vector overlay.""" + self.set_visible(True) + + def hide(self): + """Hide the vector overlay.""" + self.set_visible(False) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index bda3ba5..6310a0c 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -88,3 +88,12 @@ carta.validation module :members: :undoc-members: :show-inheritance: + +carta.vector_overlay module +--------------------------- + +.. automodule:: carta.vector_overlay + :members: + :undoc-members: + :show-inheritance: + diff --git a/tests/test_image.py b/tests/test_image.py index f9c86c3..fa88675 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -4,6 +4,7 @@ from carta.util import CartaValidationFailed from carta.constants import NumberFormat as NF, SpatialAxis as SA + # FIXTURES @@ -74,6 +75,16 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte assert image_object.image_id == 123 +# SUBOBJECTS + + +@pytest.mark.parametrize("name,classname", [ + ("vectors", "VectorOverlay"), +]) +def test_subobjects(image, name, classname): + assert getattr(image, name).__class__.__name__ == classname + + # SIMPLE PROPERTIES TODO to be completed. @pytest.mark.parametrize("property_name,expected_path", [ diff --git a/tests/test_vector_overlay.py b/tests/test_vector_overlay.py new file mode 100644 index 0000000..5fa4a86 --- /dev/null +++ b/tests/test_vector_overlay.py @@ -0,0 +1,160 @@ +import pytest + +from carta.vector_overlay import VectorOverlay +from carta.util import Macro +from carta.constants import VectorOverlaySource as VOS, Auto, Colormap as CM + + +@pytest.fixture +def vector_overlay(image): + return VectorOverlay(image) + + +@pytest.fixture +def get_value(vector_overlay, mock_get_value): + return mock_get_value(vector_overlay) + + +@pytest.fixture +def call_action(vector_overlay, mock_call_action): + return mock_call_action(vector_overlay) + + +@pytest.fixture +def image_call_action(image, mock_call_action): + return mock_call_action(image) + + +@pytest.fixture +def property_(mock_property): + return mock_property("carta.vector_overlay.VectorOverlay") + + +@pytest.fixture +def method(vector_overlay, mock_method): + return mock_method(vector_overlay) + + +@pytest.mark.parametrize("args,kwargs,expected_args", [ + # Nothing + ((), {}, None), + # Everything + ((VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5), {}, (VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5)), + # Deduce pixel averaging flag + ((), {"pixel_averaging": 1}, + ("M(angularSource)", "M(intensitySource)", True, 1, "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", "M(debiasing)", "M(qError)", "M(uError)")), + # Don't deduce pixel averaging flag + ((), {"pixel_averaging": 1, "pixel_averaging_enabled": False}, + ("M(angularSource)", "M(intensitySource)", False, 1, "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", "M(debiasing)", "M(qError)", "M(uError)")), + # Deduce threshold flag + ((), {"threshold": 2}, + ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", True, 2, "M(debiasing)", "M(qError)", "M(uError)")), + # Don't deduce threshold flag + ((), {"threshold": 2, "threshold_enabled": False}, + ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", False, 2, "M(debiasing)", "M(qError)", "M(uError)")), + # Deduce debiasing flag + ((), {"q_error": 3, "u_error": 4}, + ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", True, 3, 4)), + # Don't deduce debiasing flag + ((), {"q_error": 3, "u_error": 4, "debiasing": False}, + ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", False, 3, 4)), + # Disable debiasing (no q_error) + ((), {"u_error": 4, "debiasing": True}, + ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", False, "M(qError)", 4)), + # Disable debiasing (no u_error) + ((), {"q_error": 3, "debiasing": True}, + ("M(angularSource)", "M(intensitySource)", "M(pixelAveragingEnabled)", "M(pixelAveraging)", "M(fractionalIntensity)", "M(thresholdEnabled)", "M(threshold)", False, 3, "M(uError)")), +]) +def test_configure(vector_overlay, call_action, method, args, kwargs, expected_args): + method("macro", lambda _, v: f"M({v})") + vector_overlay.configure(*args, **kwargs) + if expected_args is None: + call_action.assert_not_called() + else: + call_action.assert_called_with("setVectorOverlayConfiguration", *expected_args) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + # Nothing + ((), {}, ()), + # Everything + ((1, 2, 3, 4, 5, 6), {}, ( + ("setThickness", 1), + ("setIntensityRange", 2, 3), + ("setLengthRange", 4, 5), + ("setRotationOffset", 6), + )), + # No intensity min; auto intensity max + ((), {"intensity_max": Auto.AUTO}, (("setIntensityRange", "M(intensityMin)", Macro.UNDEFINED),)), + # Auto intensity min; no intensity max + ((), {"intensity_min": Auto.AUTO}, (("setIntensityRange", Macro.UNDEFINED, "M(intensityMax)"),)), +]) +def test_set_style(mocker, vector_overlay, call_action, method, args, kwargs, expected_calls): + method("macro", lambda _, v: f"M({v})") + vector_overlay.set_style(*args, **kwargs) + call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) + + +def test_set_color(mocker, vector_overlay, call_action): + vector_overlay.set_color("blue") + call_action.assert_has_calls([ + mocker.call("setColor", "blue"), + mocker.call("setColormapEnabled", False), + ]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([CM.VIRIDIS, 0.5, 1.5], {}, [("setColormap", CM.VIRIDIS), ("setColormapEnabled", True), ("setColormapBias", 0.5), ("setColormapContrast", 1.5)]), + ([CM.VIRIDIS], {}, [("setColormap", CM.VIRIDIS), ("setColormapEnabled", True)]), + ([], {"bias": 0.5}, [("setColormapBias", 0.5)]), + ([], {"contrast": 1.5}, [("setColormapContrast", 1.5)]), +]) +def test_set_colormap(mocker, vector_overlay, call_action, args, kwargs, expected_calls): + vector_overlay.set_colormap(*args, **kwargs) + call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) + + +def test_apply(vector_overlay, image_call_action): + vector_overlay.apply() + image_call_action.assert_called_with("applyVectorOverlay") + + +def test_clear(vector_overlay, image_call_action): + vector_overlay.clear() + image_call_action.assert_called_with("clearVectorOverlay", True) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5, 1, 2, 3, 4, 5, 6, "blue", CM.VIRIDIS, 0.5, 1.5], {}, [("configure", VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5), ("set_style", 1, 2, 3, 4, 5, 6), ("set_color", "blue"), ("set_colormap", CM.VIRIDIS, 0.5, 1.5), ("apply",)]), + ([], {"pixel_averaging": 1, "thickness": 2, "color": "blue", "bias": 0.5}, [("configure", None, None, None, 1, None, None, None, None, None, None), ("set_style", 2, None, None, None, None, None), ("set_color", "blue"), ("set_colormap", None, 0.5, None), ("apply",)]), + ([], {"thickness": 2}, [("set_style", 2, None, None, None, None, None), ("apply",)]), +]) +def test_plot(vector_overlay, method, args, kwargs, expected_calls): + mocks = {} + for method_name in ("configure", "set_style", "set_color", "set_colormap", "apply"): + mocks[method_name] = method(method_name, None) + + vector_overlay.plot(*args, **kwargs) + + for method_name, *expected_args in expected_calls: + mocks[method_name].assert_called_with(*expected_args) + + +@pytest.mark.parametrize("state", [True, False]) +def test_set_visible(vector_overlay, call_action, state): + vector_overlay.set_visible(state) + call_action.assert_called_with("setVisible", state) + + +def test_show(vector_overlay, method): + mock_set_visible = method("set_visible", None) + vector_overlay.show() + mock_set_visible.assert_called_with(True) + + +def test_hide(vector_overlay, method): + mock_set_visible = method("set_visible", None) + vector_overlay.hide() + mock_set_visible.assert_called_with(False)