From 2343c39f50e72d1a19a164354010cdc876a30708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 27 Jul 2023 14:12:52 +0200 Subject: [PATCH 01/50] Some prototype functions; work in progress --- carta/constants.py | 11 +++++ carta/image.py | 112 +++++++++++++++++++++------------------------ carta/region.py | 31 +++++++++++++ carta/session.py | 11 ++++- carta/util.py | 69 ++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 63 deletions(-) create mode 100644 carta/region.py diff --git a/carta/constants.py b/carta/constants.py index a4a4561..118fd97 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -179,3 +179,14 @@ class GridMode(StrEnum): """Grid modes.""" DYNAMIC = "dynamic" FIXED = "fixed" + + +class FileType(IntEnum): + """File types corresponding to the protobuf enum""" + CASA = 0 + CRTF = 1 + DS9_REG = 2 + FITS = 3 + HDF5 = 4 + MIRIAD = 5 + UNKNOWN = 6 diff --git a/carta/image.py b/carta/image.py index c73dcb4..a045d5f 100644 --- a/carta/image.py +++ b/carta/image.py @@ -4,12 +4,13 @@ """ import posixpath -from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis -from .util import Macro, cached, PixelValue, AngularSize, WorldCoordinate +from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis, FileType +from .util import Macro, cached, PixelValue, AngularSize, WorldCoordinate, BasePathMixin from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate +from .region import Region -class Image: +class Image(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. @@ -112,64 +113,6 @@ def from_list(cls, session, image_list): def __repr__(self): return f"{self.session.session_id}:{self.image_id}:{self.file_name}" - def call_action(self, path, *args, **kwargs): - """Convenience wrapper for the session object's generic action method. - - This method calls :obj:`carta.session.Session.call_action` after prepending this image's base path to the path parameter. - - Parameters - ---------- - path : string - The path to an action relative to this image's frame store. - *args - A variable-length list of parameters. These are passed unmodified to the session method. - **kwargs - Arbitrary keyword parameters. These are passed unmodified to the session method. - - Returns - ------- - object or None - The unmodified return value of the session method. - """ - return self.session.call_action(f"{self._base_path}.{path}", *args, **kwargs) - - def get_value(self, path): - """Convenience wrapper for the session object's generic method for retrieving attribute values. - - This method calls :obj:`carta.session.Session.get_value` after prepending this image's base path to the *path* parameter. - - Parameters - ---------- - path : string - The path to an attribute relative to this image's frame store. - - Returns - ------- - object - The unmodified return value of the session method. - """ - return self.session.get_value(f"{self._base_path}.{path}") - - def macro(self, target, variable): - """Convenience wrapper for creating a :obj:`carta.util.Macro` for an image property. - - This method prepends this image's base path to the *target* parameter. If *target* is the empty string, the base path will be substituted. - - Parameters - ---------- - target : str - The target frontend object. - variable : str - The variable on the target object. - - Returns - ------- - :obj:carta.util.Macro - A placeholder for a variable which will be evaluated dynamically by the frontend. - """ - target = f"{self._base_path}.{target}" if target else self._base_path - return Macro(target, variable) - # METADATA @property @@ -789,6 +732,53 @@ def set_clip_percentile(self, rank): if rank not in preset_ranks: self.call_action("renderConfig.setPercentileRank", -1) # select 'custom' rank button + # REGIONS + + def region_list(self): + """Return the list of regions associated with this image. + + Returns + ------- + list of :obj:`carta.region.Region` objects. + """ + num_regions = self.get_value("regionSet.regions.length") + return [Region(self, self.get_value(f"regionSet.regions[{i}].region_id")) for i in range(num_regions)] + + @validate(String(), NoneOr(OneOf(FileType.CRTF, FileType.DS9_REG))) + def import_regions(self, path, file_type=None): + """Import regions into this image from a file. + + TODO: placeholder code; until the frontend function allows a frame to be specified, this will only work on the active image or its spatial reference + + Parameters + ---------- + path : {0} + The path to the region file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + file_type : {1} + The type of the region file. Omit this parameter to detect the type automatically from the file extension. + + Raises + ------ + ValueError + If no file format is specified, and the + """ + directory, file_name = posixpath.split(path) + + # TODO actually use the file browser to fetch info for this file? + + if file_type is None: + if file_name.endswith(".crtf"): + file_type = FileType.CRTF + elif file_name.endswith(".reg"): + file_type = FileType.DS9_REG + else: + raise ValueError("The region file type could not be inferred from the file name. Please use the file_type parameter.") + + self.session.call_action("importRegion", directory, file_name, file_type) # TODO pass in frame when this is supported + + def export_regions(self, path, file_type, coordinate_type): + pass # TODO + # CLOSE def close(self): diff --git a/carta/region.py b/carta/region.py new file mode 100644 index 0000000..df9a5f6 --- /dev/null +++ b/carta/region.py @@ -0,0 +1,31 @@ +"""This module contains a region class which represents a single region loaded in the session, and a region set class which represents all regions associated with an image, which may be shared by all spatially matched regions. + +Region objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.image.Image` object. +""" +from .util import BasePathMixin + + +class Region(BasePathMixIn): + """Utility object which provides access to one region associated with an image. + + # TODO find out what happens to region IDs when you match/unmatch or delete. + """ + def __init__(self, image, index, region_id): + self.image = image + self.session = image.session + self.index = index # TODO does it actually make sense to keep this? + self.region_id = region_id # TODO does it actually make sense to keep this? + + + self._region = Macro("", self._base_path) + + @property + def _base_path(self): + # TODO this is a problem; we can attempt a horrendous hackaround: + # get all region IDs + # then look up the index on the Python side + + return f"{image._base_path}.regionSet.regions[{index}]" + + + diff --git a/carta/session.py b/carta/session.py index 480d4ec..9612c43 100644 --- a/carta/session.py +++ b/carta/session.py @@ -254,7 +254,7 @@ def call_action(self, path, *args, **kwargs): """ return self._protocol.request_scripting_action(self.session_id, path, *args, **kwargs) - def get_value(self, path): + def get_value(self, path, return_path=None): """Get the value of an attribute from a frontend store. Like the :obj:`carta.session.Session.call_action` method, this is exposed in the public API but is not intended to be used directly under normal circumstances. @@ -263,6 +263,8 @@ def get_value(self, path): ---------- path : string The full path to the attribute. + return_path : string, optional + Specifies a subobject of the attribute value which should be returned instead of the whole object. Returns ------- @@ -271,7 +273,12 @@ def get_value(self, path): """ path, parameter = split_action_path(path) macro = Macro(path, parameter) - return self.call_action("fetchParameter", macro, response_expected=True) + + kwargs = {"response_expected": True} + if return_path is not None: + kwargs["return_path"] = return_path + + return self.call_action("fetchParameter", macro, **kwargs) # FILE BROWSING diff --git a/carta/util.py b/carta/util.py index a213266..29c42ba 100644 --- a/carta/util.py +++ b/carta/util.py @@ -557,3 +557,72 @@ def from_string(cls, value, axis): raise ValueError(f"DMS coordinate string {value} is outside the permitted latitude range [-90:00:00, 90:00:00].") return cls(D, M, S) + + +class BasePathMixin: + """A mixin which provides ``call_action`` and ``get_value`` methods which prepend the object's base path to the path before calling the corresponding :obj:`carta.session.Session` methods. + + It also provides a ``macro`` method which prepends the path when creating a :obj:`carta.util.Macro`. + + A class inheriting from this mixin must define a `_base_path` attribute (the string prefix) and a `session` attribute (a :obj:`carta.session.Session` object). + """ + + def call_action(self, path, *args, **kwargs): + """Convenience wrapper for the session object's generic action method. + + This method calls :obj:`carta.session.Session.call_action` after prepending this object's base path to the path parameter. + + Parameters + ---------- + path : string + The path to an action relative to this object's store. + *args + A variable-length list of parameters. These are passed unmodified to the session method. + **kwargs + Arbitrary keyword parameters. These are passed unmodified to the session method. + + Returns + ------- + object or None + The unmodified return value of the session method. + """ + return self.session.call_action(f"{self._base_path}.{path}", *args, **kwargs) + + def get_value(self, path, return_path=None): + """Convenience wrapper for the session object's generic method for retrieving attribute values. + + This method calls :obj:`carta.session.Session.get_value` after prepending this object's base path to the *path* parameter. + + Parameters + ---------- + path : string + The path to an attribute relative to this object's store. + return_path : string, optional + Specifies a subobject of the attribute value which should be returned instead of the whole object. + + Returns + ------- + object + The unmodified return value of the session method. + """ + return self.session.get_value(f"{self._base_path}.{path}", return_path=return_path) + + def macro(self, target, variable): + """Convenience wrapper for creating a :obj:`carta.util.Macro` for an object property. + + This method prepends this object's base path to the *target* parameter. If *target* is the empty string, the base path will be substituted. + + Parameters + ---------- + target : str + The target frontend object. + variable : str + The variable on the target object. + + Returns + ------- + :obj:carta.util.Macro + A placeholder for a variable which will be evaluated dynamically by the frontend. + """ + target = f"{self._base_path}.{target}" if target else self._base_path + return Macro(target, variable) From fb4b5117e001acc636af56f976c80b1f36b51f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 10 Aug 2023 19:49:59 +0200 Subject: [PATCH 02/50] some initial functions --- carta/constants.py | 21 +++++++++++++++++++++ carta/image.py | 39 ++++++++++++++++++++++----------------- carta/region.py | 27 ++++++++++----------------- carta/session.py | 4 ++-- carta/util.py | 4 ++-- carta/validation.py | 16 ++++++++++++++++ tests/test_image.py | 2 +- 7 files changed, 74 insertions(+), 39 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index 118fd97..7eca723 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -190,3 +190,24 @@ class FileType(IntEnum): HDF5 = 4 MIRIAD = 5 UNKNOWN = 6 + + +class RegionType(IntEnum): + """Region types corresponding to the protobuf enum""" + POINT = 0 + LINE = 1 + POLYLINE = 2 + RECTANGLE = 3 + ELLIPSE = 4 + ANNULUS = 5 + POLYGON = 6 + ANNPOINT = 7 + ANNLINE = 8 + ANNPOLYLINE = 9 + ANNRECTANGLE = 10 + ANNELLIPSE = 11 + ANNPOLYGON = 12 + ANNVECTOR = 13 + ANNRULER = 14 + ANNTEXT = 15 + ANNCOMPASS = 16 diff --git a/carta/image.py b/carta/image.py index a045d5f..0b87dfd 100644 --- a/carta/image.py +++ b/carta/image.py @@ -4,9 +4,9 @@ """ import posixpath -from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis, FileType +from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis, FileType, RegionType from .util import Macro, cached, PixelValue, AngularSize, WorldCoordinate, BasePathMixin -from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate +from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, String, Point from .region import Region @@ -733,7 +733,7 @@ def set_clip_percentile(self, rank): self.call_action("renderConfig.setPercentileRank", -1) # select 'custom' rank button # REGIONS - + def region_list(self): """Return the list of regions associated with this image. @@ -741,31 +741,30 @@ def region_list(self): ------- list of :obj:`carta.region.Region` objects. """ - num_regions = self.get_value("regionSet.regions.length") - return [Region(self, self.get_value(f"regionSet.regions[{i}].region_id")) for i in range(num_regions)] - + region_ids = self.get_value("regionSet.regionIds") + return [Region(self, region_id) for region_id in region_ids] + @validate(String(), NoneOr(OneOf(FileType.CRTF, FileType.DS9_REG))) def import_regions(self, path, file_type=None): """Import regions into this image from a file. - - TODO: placeholder code; until the frontend function allows a frame to be specified, this will only work on the active image or its spatial reference - + Parameters ---------- path : {0} The path to the region file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. file_type : {1} The type of the region file. Omit this parameter to detect the type automatically from the file extension. - + Raises ------ ValueError If no file format is specified, and the """ directory, file_name = posixpath.split(path) - + # TODO actually use the file browser to fetch info for this file? - + # TODO merge in the hypercube PR first? + if file_type is None: if file_name.endswith(".crtf"): file_type = FileType.CRTF @@ -773,12 +772,18 @@ def import_regions(self, path, file_type=None): file_type = FileType.DS9_REG else: raise ValueError("The region file type could not be inferred from the file name. Please use the file_type parameter.") - - self.session.call_action("importRegion", directory, file_name, file_type) # TODO pass in frame when this is supported - + + self.session.call_action("importRegion", directory, file_name, file_type, self._frame) + def export_regions(self, path, file_type, coordinate_type): - pass # TODO - + pass # TODO + + @validate(Constant(RegionType), IterableOf(Point()), Number(), String()) + def add_region(self, region_type, points, rotation, name): + """Add a new region to this image.""" + pass # TODO call Region.new + # TODO actually create individual functions with validation for the different types?? + # CLOSE def close(self): diff --git a/carta/region.py b/carta/region.py index df9a5f6..fbb24be 100644 --- a/carta/region.py +++ b/carta/region.py @@ -2,30 +2,23 @@ Region objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.image.Image` object. """ -from .util import BasePathMixin +from .util import Macro, BasePathMixIn class Region(BasePathMixIn): """Utility object which provides access to one region associated with an image. - + # TODO find out what happens to region IDs when you match/unmatch or delete. """ - def __init__(self, image, index, region_id): + + def __init__(self, image, region_id): self.image = image self.session = image.session - self.index = index # TODO does it actually make sense to keep this? - self.region_id = region_id # TODO does it actually make sense to keep this? - - - self._region = Macro("", self._base_path) + self.region_id = region_id - @property - def _base_path(self): - # TODO this is a problem; we can attempt a horrendous hackaround: - # get all region IDs - # then look up the index on the Python side - - return f"{image._base_path}.regionSet.regions[{index}]" - + self._base_path = f"{image._base_path}.regionSet.regionMap[{region_id}]" + self._region = Macro("", self._base_path) - + # @classmethod + # def new(???): + # TODO create new region -- low-level method; use generic new region method directly? diff --git a/carta/session.py b/carta/session.py index 9612c43..b7980ed 100644 --- a/carta/session.py +++ b/carta/session.py @@ -273,11 +273,11 @@ def get_value(self, path, return_path=None): """ path, parameter = split_action_path(path) macro = Macro(path, parameter) - + kwargs = {"response_expected": True} if return_path is not None: kwargs["return_path"] = return_path - + return self.call_action("fetchParameter", macro, **kwargs) # FILE BROWSING diff --git a/carta/util.py b/carta/util.py index 29c42ba..07c3a8d 100644 --- a/carta/util.py +++ b/carta/util.py @@ -561,9 +561,9 @@ def from_string(cls, value, axis): class BasePathMixin: """A mixin which provides ``call_action`` and ``get_value`` methods which prepend the object's base path to the path before calling the corresponding :obj:`carta.session.Session` methods. - + It also provides a ``macro`` method which prepends the path when creating a :obj:`carta.util.Macro`. - + A class inheriting from this mixin must define a `_base_path` attribute (the string prefix) and a `session` attribute (a :obj:`carta.session.Session` object). """ diff --git a/carta/validation.py b/carta/validation.py index c6464d9..1dcdb3e 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -675,6 +675,22 @@ def __init__(self): super().__init__(*options, description="a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units") +class Point(Union): + """A representation of a 2D point, either as a dictionary with ``'x'`` and ``'y'`` as keys and numeric values, or an iterable with two numeric values (which will be evaluated as ``x`` and ``y`` coordinates in order).""" + + # TODO make this inherit from MapOf and add check for keys + class PointDict(Parameter): + """Helper validator class for evaluating points in dictionary format.""" + pass + + def __init__(self): + options = ( + IterableOf(Number(), min_size=2, max_size=2), + self.PointDict(), + ) + super().__init__(*options, description="a dictionary with ``'x'`` and ``'y'`` as keys and numeric values, or an iterable with two numeric values") + + class Attr(str): """A wrapper for arguments to be passed to the :obj:`carta.validation.Evaluate` descriptor. These arguments are string names of properties on the parent object of the decorated method, which will be evaluated at runtime.""" pass diff --git a/tests/test_image.py b/tests/test_image.py index a98384e..8befd36 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -127,7 +127,7 @@ def test_new(session, mock_session_call_action, mock_session_method, args, kwarg mock_session_call_action.assert_called_with(*expected_params, return_path='frameInfo.fileId') - assert type(image_object) == Image + assert type(image_object) is Image assert image_object.session == session assert image_object.image_id == 123 assert image_object.file_name == expected_params[2] From 35453e7aab0391134d3f8294ea0c97f8428491d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 10 Aug 2023 21:59:25 +0200 Subject: [PATCH 03/50] Add region module to docs --- docs/source/carta.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index bda3ba5..dcbee0c 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -49,6 +49,14 @@ carta.protocol module :undoc-members: :show-inheritance: +carta.region module +------------------- + +.. automodule:: carta.region + :members: + :undoc-members: + :show-inheritance: + carta.session module -------------------- From e1a36894cde18a967abd5faf69878a7f2ddc664b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 15 Aug 2023 14:46:01 +0200 Subject: [PATCH 04/50] COmpleted and revised some functionality --- carta/constants.py | 14 +++- carta/image.py | 65 ++------------- carta/region.py | 195 ++++++++++++++++++++++++++++++++++++++++++-- carta/session.py | 11 ++- carta/util.py | 28 +++++++ carta/validation.py | 23 ++++-- 6 files changed, 259 insertions(+), 77 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index 97a9e4b..4c48698 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -187,7 +187,7 @@ class GridMode(StrEnum): class FileType(IntEnum): - """File types corresponding to the protobuf enum""" + """File types corresponding to the protobuf enum.""" CASA = 0 CRTF = 1 DS9_REG = 2 @@ -198,7 +198,11 @@ class FileType(IntEnum): class RegionType(IntEnum): - """Region types corresponding to the protobuf enum""" + """Region types corresponding to the protobuf enum.""" + + def __init__(self, value): + self.is_annotation = self.name.startswith("ANN") + POINT = 0 LINE = 1 POLYLINE = 2 @@ -216,3 +220,9 @@ class RegionType(IntEnum): ANNRULER = 14 ANNTEXT = 15 ANNCOMPASS = 16 + + +class CoordinateType(IntEnum): + """Coordinate types corresponding to the protobuf enum.""" + PIXEL = 0 + WORLD = 1 diff --git a/carta/image.py b/carta/image.py index e6926be..8774a6a 100644 --- a/carta/image.py +++ b/carta/image.py @@ -3,13 +3,11 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ -import posixpath - -from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis, FileType, RegionType +from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, CoordinateSystem, SpatialAxis from .util import Macro, cached, BasePathMixin from .units import PixelValue, AngularSize, WorldCoordinate -from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional, String, Point -from .region import Region +from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional +from .region import RegionSet from .metadata import parse_header @@ -31,11 +29,15 @@ class Image(BasePathMixin): The session object associated with this image. image_id : integer The ID identifying this image within the session. + regions : :obj:`carta.region.RegionSet` object + Functions for manipulating regions associated with this image. """ def __init__(self, session, image_id): self.session = session self.image_id = image_id + + self.regions = RegionSet(self) self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) @@ -675,58 +677,7 @@ def set_clip_percentile(self, rank): self.call_action("renderConfig.setPercentileRank", rank) if rank not in preset_ranks: self.call_action("renderConfig.setPercentileRank", -1) # select 'custom' rank button - - # REGIONS - - def region_list(self): - """Return the list of regions associated with this image. - - Returns - ------- - list of :obj:`carta.region.Region` objects. - """ - region_ids = self.get_value("regionSet.regionIds") - return [Region(self, region_id) for region_id in region_ids] - - @validate(String(), NoneOr(OneOf(FileType.CRTF, FileType.DS9_REG))) - def import_regions(self, path, file_type=None): - """Import regions into this image from a file. - - Parameters - ---------- - path : {0} - The path to the region file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. - file_type : {1} - The type of the region file. Omit this parameter to detect the type automatically from the file extension. - - Raises - ------ - ValueError - If no file format is specified, and the - """ - directory, file_name = posixpath.split(path) - - # TODO actually use the file browser to fetch info for this file? - # TODO merge in the hypercube PR first? - - if file_type is None: - if file_name.endswith(".crtf"): - file_type = FileType.CRTF - elif file_name.endswith(".reg"): - file_type = FileType.DS9_REG - else: - raise ValueError("The region file type could not be inferred from the file name. Please use the file_type parameter.") - - self.session.call_action("importRegion", directory, file_name, file_type, self._frame) - - def export_regions(self, path, file_type, coordinate_type): - pass # TODO - - @validate(Constant(RegionType), IterableOf(Point()), Number(), String()) - def add_region(self, region_type, points, rotation, name): - """Add a new region to this image.""" - pass # TODO call Region.new - # TODO actually create individual functions with validation for the different types?? + # CLOSE diff --git a/carta/region.py b/carta/region.py index fbb24be..a33e7a4 100644 --- a/carta/region.py +++ b/carta/region.py @@ -1,8 +1,140 @@ -"""This module contains a region class which represents a single region loaded in the session, and a region set class which represents all regions associated with an image, which may be shared by all spatially matched regions. +"""This module contains region classes which represent single regions or annotations loaded in the session, and a region set class which represents all regions and annotations associated with an image. -Region objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.image.Image` object. +Region and annotation objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.region.RegionSet` object. """ -from .util import Macro, BasePathMixIn + +import posixpath + +from .util import Macro, BasePathMixIn, Point as Pt +from .constants import FileType, RegionType, CoordinateType +from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf + + +class RegionSet(BasePathMixIn): + """Utility object for collecting region-related image functions.""" + + def __init__(self, image): + self.image = image + self.session = image.session + self._base_path = f"{image._base_path}.regionSet" + + def list(self): + """Return the list of regions associated with this image. + + Returns + ------- + list of :obj:`carta.region.Region` objects. + """ + region_list = self.get_value("regionList") + return Region.from_list(self.image, region_list) + + @validate(String()) + def import_from_file(self, path): + """Import regions into this image from a file. + + Parameters + ---------- + path : {0} + The path to the region file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + + Raises + ------ + CartaActionFailed + If the file does not exist or is not a region file. + """ + directory, file_name = posixpath.split(path) + + file_type = FileType(self.session.call_action("backendService.getRegionFileInfo", directory, file_name, return_path="fileInfo.type")) + + self.session.call_action("importRegion", directory, file_name, file_type, self.image._frame) + + @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG), NoneOr(IterableOf(Number()))) + def export_to_file(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF, region_ids=None): + """Export regions from this image into a file. + + Parameters + ---------- + path : {0} + The path where the file should be saved, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + coordinate_type : {1} + The coordinate type to use (world coordinates by default). + file_type : {2} + The region file type to use (CRTF by default). + region_ids : {3} + The region IDs to include. By default all regions will be included (except the cursor). + """ + directory, file_name = posixpath.split(path) + + if region_ids is None: + region_ids = self.get_value("regionIds")[1:] + + self.session.call_action("exportRegions", directory, file_name, coordinate_type, file_type, region_ids, self.image._frame) + + @validate(Constant(RegionType), IterableOf(Point()), Number(), String()) + def add_region(self, region_type, points, rotation=0, name=""): + """Add a new region to this image. + + This is a generic low-level function. Also see the higher-level functions for adding regions of specific types, like :obj:`carta.image.add_region_rectangular`. + + Parameters + ---------- + region_type : {0} + The type of the region. + points : {1} + The control points defining the region. How these values are interpreted depends on the region type. TODO: we need to convert possible world coordinates to image coordinates here. + rotation : {2} + The rotation of the region, in degrees. + name : {3} + The name of the region. Defaults to the empty string. + """ + return Region.new(self, region_type, points, rotation, name) + + @validate(Point(), Boolean(), String()) + def add_point(self, center, annotation=False, name=""): + region_type = RegionType.ANNPOINT if annotation else RegionType.POINT + return self.add_region(region_type, [center], name=name) + + @validate(Point(), Number(), Number(), Boolean(), String()) + def add_rectangle(self, center, width, height, annotation=False, rotation=0, name=""): + region_type = RegionType.ANNRECTANGLE if annotation else RegionType.RECTANGLE + return self.add_region(region_type, [center, [width, height]], rotation, name) + + @validate(Point(), Number(), Number(), Boolean(), String()) + def add_ellipse(self, center, semi_major, semi_minor, annotation=False, rotation=0, name=""): + region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE + return self.add_region(region_type, [center, [semi_major, semi_minor]], rotation, name) + + @validate(IterableOf(Point()), Boolean(), Number(), String()) + def add_polygon(self, points, annotation=False, rotation=0, name=""): + region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON + return self.add_region(region_type, points, rotation, name) + + @validate(Point(), Point(), Boolean(), Number(), String()) + def add_line(self, start, end, annotation=False, rotation=0, name=""): + region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON + return self.add_region(region_type, [start, end], rotation, name) + + @validate(IterableOf(Point()), Boolean(), Number(), String()) + def add_polyline(self, points, annotation=False, rotation=0, name=""): + region_type = RegionType.ANNPOLYLINE if annotation else RegionType.POLYLINE + return self.add_region(region_type, points, rotation, name) + + @validate(IterableOf(Point()), Number(), String()) + def add_vector(self, points, rotation=0, name=""): + return self.add_region(RegionType.ANNVECTOR, points, rotation, name) + + @validate(Point(), Number(), Number(), String()) + def add_text(self, center, width, height, rotation=0, name=""): + # TODO where is the text set? We should do that in one step. + return self.add_region(RegionType.ANNTEXT, [center, [width, height]], rotation, name) + + @validate(Point(), Number(), Number(), String()) + def add_compass(self, center, length, rotation=0, name=""): + return self.add_region(RegionType.ANNCOMPASS, [center, [length, length]], rotation, name) + + @validate(Point(), Point(), Number(), String()) + def add_ruler(self, start, end, rotation=0, name=""): + return self.add_region(RegionType.ANNRULER, [start, end], rotation, name) class Region(BasePathMixIn): @@ -10,6 +142,15 @@ class Region(BasePathMixIn): # TODO find out what happens to region IDs when you match/unmatch or delete. """ + + REGION_TYPE = None + CUSTOM_CLASS = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + + if cls.REGION_TYPE is not None: + Region.CUSTOM_CLASS[cls.REGION_TYPE] = cls def __init__(self, image, region_id): self.image = image @@ -18,7 +159,49 @@ def __init__(self, image, region_id): self._base_path = f"{image._base_path}.regionSet.regionMap[{region_id}]" self._region = Macro("", self._base_path) + + @classmethod + @validate(Constant(RegionType)) + def region_class(cls, region_type): + return cls.CUSTOM_CLASS.get(region_type, Annotation if region_type.is_annotation else Region) + + @classmethod + @validate(Constant(RegionType), IterableOf(Point()), Number(), String()) + def new(cls, image, region_type, points, rotation=0, name=""): + points = [Pt.from_object(point) for point in points] + region_id = image.call_action("regionSet.addRegionAsync", region_type, points, rotation, name, return_path="regionId") + return cls.region_class(region_type)(image, region_id) + + @classmethod + @validate(IterableOf(Number())) + def from_list(cls, image, region_list): + return [cls.region_class(RegionType(r["type"]))(image, r["id"]) for r in region_list] + + @property + def region_type(self): + return RegionType(self.get_value("regionType")) + + +class Annotation(Region): + """Base class for annotations.""" + pass + + +class PointAnnotation(Annotation): + REGION_TYPE = RegionType.ANNPOINT + + +class TextAnnotation(Annotation): + REGION_TYPE = RegionType.ANNTEXT + + +class VectorAnnotation(Annotation): + REGION_TYPE = RegionType.ANNVECTOR + + +class CompassAnnotation(Annotation): + REGION_TYPE = RegionType.ANNCOMPASS + - # @classmethod - # def new(???): - # TODO create new region -- low-level method; use generic new region method directly? +class RulerAnnotation(Annotation): + REGION_TYPE = RegionType.ANNRULER diff --git a/carta/session.py b/carta/session.py index 37a6a57..831fa45 100644 --- a/carta/session.py +++ b/carta/session.py @@ -13,7 +13,7 @@ from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ComplexComponent, NumberFormat from .backend import Backend from .protocol import Protocol -from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl +from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf @@ -323,8 +323,11 @@ def ls(self): list The list of files and subdirectories in the frontend file browser's current starting directory. """ - self.call_action("fileBrowserStore.getFileList", self.pwd()) - file_list = self.get_value("fileBrowserStore.fileList") + #self.call_action("fileBrowserStore.getFileList", self.pwd()) + #file_list = self.get_value("fileBrowserStore.fileList") + + + file_list = self.call_action("backendService.getFileList", self.pwd(), 2) items = [] if "files" in file_list: items.extend([f["name"] for f in file_list["files"]]) @@ -872,7 +875,7 @@ def set_cursor(self, x, y): The Y position. """ - self.active_frame().call_action("regionSet.regions[0].setControlPoint", 0, [x, y]) + self.active_frame().call_action("regionSet.updateCursorRegionPosition", Point(x, y)) # SAVE IMAGE diff --git a/carta/util.py b/carta/util.py index bbc2842..fa65458 100644 --- a/carta/util.py +++ b/carta/util.py @@ -205,3 +205,31 @@ def macro(self, target, variable): """ target = f"{self._base_path}.{target}" if target else self._base_path return Macro(target, variable) + + +def Point: + """A representation of a 2D point. + + Parameters + ---------- + x : number + The *x* coordinate of the point. + y : number + The *y* coordinate of the point. + """ + + def __init__(self, x, y): + self.x = x + self.y = y + + @classmethod + def from_object(cls, obj): + if isinstance(obj, Point): + return obj + if isinstance(obj, dict): + return cls(**obj) + return cls(*obj) + + def json(self): + """The JSON serialization of this object.""" + return {"x": self.x, "y": self.y} diff --git a/carta/validation.py b/carta/validation.py index 6c5fc23..a296f96 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -4,7 +4,7 @@ import functools import inspect -from .util import CartaValidationFailed +from .util import CartaValidationFailed, Point as Pt from .units import PixelValue, AngularSize, WorldCoordinate @@ -704,7 +704,7 @@ def __init__(self): class Coordinate(Union): - """A string representation of a world coordinate or image coordinate. Can be a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units. Validates strings using :obj:`carta.util.PixelValue` and :obj:`carta.util.WorldCoordinate`.""" + """A representation of a world coordinate or image coordinate. Can be a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units. Validates strings using :obj:`carta.util.PixelValue` and :obj:`carta.util.WorldCoordinate`.""" class WorldCoordinate(String): """Helper validator class which uses :obj:`carta.util.WorldCoordinate` to validate strings.""" @@ -724,19 +724,26 @@ def __init__(self): class Point(Union): - """A representation of a 2D point, either as a dictionary with ``'x'`` and ``'y'`` as keys and numeric values, or an iterable with two numeric values (which will be evaluated as ``x`` and ``y`` coordinates in order).""" + """A representation of a 2D point, either as a :obj:`carta.util.Point` object, or a dictionary with ``'x'`` and ``'y'`` as keys and coordinate values, or an iterable with two coordinate values (which will be evaluated as ``x`` and ``y`` coordinates in order). World or image coordinates are permitted.""" - # TODO make this inherit from MapOf and add check for keys - class PointDict(Parameter): + class PointDict(MapOf): """Helper validator class for evaluating points in dictionary format.""" - pass + + def __init__(self): + super().__init__(String(), Coordinate(), min_size=2, max_size=2) + + def validate(self, value, parent): + super().validate(value, parent) + if sorted(value.keys()) != ["x", "y"]: + raise ValueError(f"{value} does not contain expected 'x' and 'y' keys.") def __init__(self): options = ( - IterableOf(Number(), min_size=2, max_size=2), + InstanceOf(Pt), + IterableOf(Coordinate(), min_size=2, max_size=2), self.PointDict(), ) - super().__init__(*options, description="a dictionary with ``'x'`` and ``'y'`` as keys and numeric values, or an iterable with two numeric values") + super().__init__(*options, description="a Point object, a dictionary with ``'x'`` and ``'y'`` as keys and coordinate values, or an iterable with two coordinate values") class Attr(str): From f596a3ea7f3f2c0274bedebb1487ed7dfe224f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 15 Aug 2023 14:47:23 +0200 Subject: [PATCH 05/50] syntax fix and formatting pass --- carta/constants.py | 2 +- carta/image.py | 3 +-- carta/region.py | 42 +++++++++++++++++++++--------------------- carta/session.py | 4 ---- carta/util.py | 8 ++++---- carta/validation.py | 4 ++-- 6 files changed, 29 insertions(+), 34 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index 4c48698..7e40c0b 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -199,7 +199,7 @@ class FileType(IntEnum): class RegionType(IntEnum): """Region types corresponding to the protobuf enum.""" - + def __init__(self, value): self.is_annotation = self.name.startswith("ANN") diff --git a/carta/image.py b/carta/image.py index 8774a6a..09679e6 100644 --- a/carta/image.py +++ b/carta/image.py @@ -36,7 +36,7 @@ class Image(BasePathMixin): def __init__(self, session, image_id): self.session = session self.image_id = image_id - + self.regions = RegionSet(self) self._base_path = f"frameMap[{image_id}]" @@ -677,7 +677,6 @@ def set_clip_percentile(self, rank): self.call_action("renderConfig.setPercentileRank", rank) if rank not in preset_ranks: self.call_action("renderConfig.setPercentileRank", -1) # select 'custom' rank button - # CLOSE diff --git a/carta/region.py b/carta/region.py index a33e7a4..6a92985 100644 --- a/carta/region.py +++ b/carta/region.py @@ -12,12 +12,12 @@ class RegionSet(BasePathMixIn): """Utility object for collecting region-related image functions.""" - + def __init__(self, image): self.image = image self.session = image.session self._base_path = f"{image._base_path}.regionSet" - + def list(self): """Return the list of regions associated with this image. @@ -47,11 +47,11 @@ def import_from_file(self, path): file_type = FileType(self.session.call_action("backendService.getRegionFileInfo", directory, file_name, return_path="fileInfo.type")) self.session.call_action("importRegion", directory, file_name, file_type, self.image._frame) - + @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG), NoneOr(IterableOf(Number()))) def export_to_file(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF, region_ids=None): """Export regions from this image into a file. - + Parameters ---------- path : {0} @@ -64,18 +64,18 @@ def export_to_file(self, path, coordinate_type=CoordinateType.WORLD, file_type=F The region IDs to include. By default all regions will be included (except the cursor). """ directory, file_name = posixpath.split(path) - + if region_ids is None: region_ids = self.get_value("regionIds")[1:] - + self.session.call_action("exportRegions", directory, file_name, coordinate_type, file_type, region_ids, self.image._frame) @validate(Constant(RegionType), IterableOf(Point()), Number(), String()) def add_region(self, region_type, points, rotation=0, name=""): """Add a new region to this image. - + This is a generic low-level function. Also see the higher-level functions for adding regions of specific types, like :obj:`carta.image.add_region_rectangular`. - + Parameters ---------- region_type : {0} @@ -88,7 +88,7 @@ def add_region(self, region_type, points, rotation=0, name=""): The name of the region. Defaults to the empty string. """ return Region.new(self, region_type, points, rotation, name) - + @validate(Point(), Boolean(), String()) def add_point(self, center, annotation=False, name=""): region_type = RegionType.ANNPOINT if annotation else RegionType.POINT @@ -98,27 +98,27 @@ def add_point(self, center, annotation=False, name=""): def add_rectangle(self, center, width, height, annotation=False, rotation=0, name=""): region_type = RegionType.ANNRECTANGLE if annotation else RegionType.RECTANGLE return self.add_region(region_type, [center, [width, height]], rotation, name) - - @validate(Point(), Number(), Number(), Boolean(), String()) + + @validate(Point(), Number(), Number(), Boolean(), String()) def add_ellipse(self, center, semi_major, semi_minor, annotation=False, rotation=0, name=""): region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE return self.add_region(region_type, [center, [semi_major, semi_minor]], rotation, name) - + @validate(IterableOf(Point()), Boolean(), Number(), String()) def add_polygon(self, points, annotation=False, rotation=0, name=""): region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON return self.add_region(region_type, points, rotation, name) - + @validate(Point(), Point(), Boolean(), Number(), String()) def add_line(self, start, end, annotation=False, rotation=0, name=""): region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON return self.add_region(region_type, [start, end], rotation, name) - + @validate(IterableOf(Point()), Boolean(), Number(), String()) def add_polyline(self, points, annotation=False, rotation=0, name=""): region_type = RegionType.ANNPOLYLINE if annotation else RegionType.POLYLINE return self.add_region(region_type, points, rotation, name) - + @validate(IterableOf(Point()), Number(), String()) def add_vector(self, points, rotation=0, name=""): return self.add_region(RegionType.ANNVECTOR, points, rotation, name) @@ -142,13 +142,13 @@ class Region(BasePathMixIn): # TODO find out what happens to region IDs when you match/unmatch or delete. """ - + REGION_TYPE = None CUSTOM_CLASS = {} - + def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) - + if cls.REGION_TYPE is not None: Region.CUSTOM_CLASS[cls.REGION_TYPE] = cls @@ -159,7 +159,7 @@ def __init__(self, image, region_id): self._base_path = f"{image._base_path}.regionSet.regionMap[{region_id}]" self._region = Macro("", self._base_path) - + @classmethod @validate(Constant(RegionType)) def region_class(cls, region_type): @@ -171,12 +171,12 @@ def new(cls, image, region_type, points, rotation=0, name=""): points = [Pt.from_object(point) for point in points] region_id = image.call_action("regionSet.addRegionAsync", region_type, points, rotation, name, return_path="regionId") return cls.region_class(region_type)(image, region_id) - + @classmethod @validate(IterableOf(Number())) def from_list(cls, image, region_list): return [cls.region_class(RegionType(r["type"]))(image, r["id"]) for r in region_list] - + @property def region_type(self): return RegionType(self.get_value("regionType")) diff --git a/carta/session.py b/carta/session.py index 831fa45..bb07d81 100644 --- a/carta/session.py +++ b/carta/session.py @@ -323,10 +323,6 @@ def ls(self): list The list of files and subdirectories in the frontend file browser's current starting directory. """ - #self.call_action("fileBrowserStore.getFileList", self.pwd()) - #file_list = self.get_value("fileBrowserStore.fileList") - - file_list = self.call_action("backendService.getFileList", self.pwd(), 2) items = [] if "files" in file_list: diff --git a/carta/util.py b/carta/util.py index fa65458..133205a 100644 --- a/carta/util.py +++ b/carta/util.py @@ -207,9 +207,9 @@ def macro(self, target, variable): return Macro(target, variable) -def Point: +class Point: """A representation of a 2D point. - + Parameters ---------- x : number @@ -217,11 +217,11 @@ def Point: y : number The *y* coordinate of the point. """ - + def __init__(self, x, y): self.x = x self.y = y - + @classmethod def from_object(cls, obj): if isinstance(obj, Point): diff --git a/carta/validation.py b/carta/validation.py index a296f96..77ed26b 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -728,10 +728,10 @@ class Point(Union): class PointDict(MapOf): """Helper validator class for evaluating points in dictionary format.""" - + def __init__(self): super().__init__(String(), Coordinate(), min_size=2, max_size=2) - + def validate(self, value, parent): super().validate(value, parent) if sorted(value.keys()) != ["x", "y"]: From 6f9b40f1307c75173e069b4f99e9472354a45a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 15 Aug 2023 14:49:40 +0200 Subject: [PATCH 06/50] refactor --- carta/session.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/carta/session.py b/carta/session.py index bb07d81..45358e8 100644 --- a/carta/session.py +++ b/carta/session.py @@ -13,7 +13,7 @@ from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ComplexComponent, NumberFormat from .backend import Backend from .protocol import Protocol -from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point +from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf @@ -861,7 +861,7 @@ def toggle_labels(self): 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. + 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 ---------- @@ -871,7 +871,7 @@ def set_cursor(self, x, y): The Y position. """ - self.active_frame().call_action("regionSet.updateCursorRegionPosition", Point(x, y)) + self.active_frame().regions.call_action("updateCursorRegionPosition", Pt(x, y)) # SAVE IMAGE From 88aa8e234182aebe5ef4d3619896eb4d1ce8b30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 15 Aug 2023 15:01:16 +0200 Subject: [PATCH 07/50] Bumped version; moved version string to text file to be read dynamically by setup and docs; fixed typo in import --- VERSION.txt | 1 + carta/region.py | 6 +++--- docs/source/conf.py | 6 +++++- setup.py | 9 ++++++--- 4 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 VERSION.txt diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..26aaba0 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +1.2.0 diff --git a/carta/region.py b/carta/region.py index 6a92985..ad510d2 100644 --- a/carta/region.py +++ b/carta/region.py @@ -5,12 +5,12 @@ import posixpath -from .util import Macro, BasePathMixIn, Point as Pt +from .util import Macro, BasePathMixin, Point as Pt from .constants import FileType, RegionType, CoordinateType from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf -class RegionSet(BasePathMixIn): +class RegionSet(BasePathMixin): """Utility object for collecting region-related image functions.""" def __init__(self, image): @@ -137,7 +137,7 @@ def add_ruler(self, start, end, rotation=0, name=""): return self.add_region(RegionType.ANNRULER, [start, end], rotation, name) -class Region(BasePathMixIn): +class Region(BasePathMixin): """Utility object which provides access to one region associated with an image. # TODO find out what happens to region IDs when you match/unmatch or delete. diff --git a/docs/source/conf.py b/docs/source/conf.py index 0e2996c..78ce9e7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,7 +23,11 @@ author = 'Adrianna Pińska' # The full version, including alpha/beta/rc tags -release = '1.0.0-beta' + +with open("../../VERSION.txt") as f: + version = f.read() + +release = version # -- General configuration --------------------------------------------------- diff --git a/setup.py b/setup.py index 28cca56..60f3e98 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,14 @@ import setuptools -with open("README.md", "r") as fh: - long_description = fh.read() +with open("README.md") as f: + long_description = f.read() + +with open("VERSION.txt") as f: + version = f.read() setuptools.setup( name="carta", - version="1.1.10", + version=version, author="Adrianna Pińska", author_email="adrianna.pinska@gmail.com", description="CARTA scripting wrapper written in Python", From cf1f2d399f74331672d60b6ea363b3d9847b99e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 15 Aug 2023 16:19:05 +0200 Subject: [PATCH 08/50] Fixed some bugs; partially tested adding new regions --- carta/constants.py | 1 + carta/image.py | 4 ++-- carta/region.py | 34 +++++++++++++++++++--------------- carta/validation.py | 18 +++++++++++------- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index 7e40c0b..ab38f9a 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -202,6 +202,7 @@ class RegionType(IntEnum): def __init__(self, value): self.is_annotation = self.name.startswith("ANN") + self.label = f"{self.name[3:].title()} - Ann" if self.is_annotation else self.name.title() POINT = 0 LINE = 1 diff --git a/carta/image.py b/carta/image.py index 09679e6..a507652 100644 --- a/carta/image.py +++ b/carta/image.py @@ -37,11 +37,11 @@ def __init__(self, session, image_id): self.session = session self.image_id = image_id - self.regions = RegionSet(self) - self._base_path = f"frameMap[{image_id}]" self._frame = Macro("", self._base_path) + self.regions = RegionSet(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/region.py b/carta/region.py index ad510d2..29a199d 100644 --- a/carta/region.py +++ b/carta/region.py @@ -5,9 +5,9 @@ import posixpath -from .util import Macro, BasePathMixin, Point as Pt +from .util import Macro, BasePathMixin, Point as Pt, cached from .constants import FileType, RegionType, CoordinateType -from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf +from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf class RegionSet(BasePathMixin): @@ -26,7 +26,7 @@ def list(self): list of :obj:`carta.region.Region` objects. """ region_list = self.get_value("regionList") - return Region.from_list(self.image, region_list) + return Region.from_list(self, region_list) @validate(String()) def import_from_file(self, path): @@ -111,7 +111,7 @@ def add_polygon(self, points, annotation=False, rotation=0, name=""): @validate(Point(), Point(), Boolean(), Number(), String()) def add_line(self, start, end, annotation=False, rotation=0, name=""): - region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON + region_type = RegionType.ANNLINE if annotation else RegionType.LINE return self.add_region(region_type, [start, end], rotation, name) @validate(IterableOf(Point()), Boolean(), Number(), String()) @@ -152,32 +152,36 @@ def __init_subclass__(cls, **kwargs): if cls.REGION_TYPE is not None: Region.CUSTOM_CLASS[cls.REGION_TYPE] = cls - def __init__(self, image, region_id): - self.image = image - self.session = image.session + def __init__(self, region_set, region_id): + self.region_set = region_set + self.session = region_set.session self.region_id = region_id - self._base_path = f"{image._base_path}.regionSet.regionMap[{region_id}]" + self._base_path = f"{region_set._base_path}.regionMap[{region_id}]" self._region = Macro("", self._base_path) + def __repr__(self): + return f"{self.region_id}:{self.region_type.label}" + @classmethod @validate(Constant(RegionType)) def region_class(cls, region_type): return cls.CUSTOM_CLASS.get(region_type, Annotation if region_type.is_annotation else Region) @classmethod - @validate(Constant(RegionType), IterableOf(Point()), Number(), String()) - def new(cls, image, region_type, points, rotation=0, name=""): + @validate(InstanceOf(RegionSet), Constant(RegionType), IterableOf(Point()), Number(), String()) + def new(cls, region_set, region_type, points, rotation=0, name=""): points = [Pt.from_object(point) for point in points] - region_id = image.call_action("regionSet.addRegionAsync", region_type, points, rotation, name, return_path="regionId") - return cls.region_class(region_type)(image, region_id) + region_id = region_set.call_action("addRegionAsync", region_type, points, rotation, name, return_path="regionId") + return cls.region_class(region_type)(region_set, region_id) @classmethod - @validate(IterableOf(Number())) - def from_list(cls, image, region_list): - return [cls.region_class(RegionType(r["type"]))(image, r["id"]) for r in region_list] + @validate(InstanceOf(RegionSet), IterableOf(MapOf(String(), Number(), required_keys={"type", "id"}))) + def from_list(cls, region_set, region_list): + return [cls.region_class(RegionType(r["type"]))(region_set, r["id"]) for r in region_list] @property + @cached def region_type(self): return RegionType(self.get_value("regionType")) diff --git a/carta/validation.py b/carta/validation.py index 77ed26b..b9984c7 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -553,8 +553,10 @@ class MapOf(IterableOf): The value parameter descriptor. """ - def __init__(self, key_param, value_param, min_size=None, max_size=None): + def __init__(self, key_param, value_param, min_size=None, max_size=None, required_keys=set(), exact_keys=False): self.value_param = value_param + self.required_keys = required_keys + self.exact_keys = exact_keys super().__init__(key_param, min_size, max_size) def validate(self, value, parent): @@ -571,6 +573,13 @@ def validate(self, value, parent): raise ValueError(f"{value} is not a dictionary, but {self.description} was expected.") raise e + if self.exact_keys: + if self.required_keys != set(value): + raise ValueError(f"Dictionary {value} does not have required exact keys {self.required_keys}.") + else: + if not self.required_keys <= set(value): + raise ValueError(f"Required keys {self.required_keys} not found in dictionary {value}.") + super().validate(value, parent) @property @@ -730,12 +739,7 @@ class PointDict(MapOf): """Helper validator class for evaluating points in dictionary format.""" def __init__(self): - super().__init__(String(), Coordinate(), min_size=2, max_size=2) - - def validate(self, value, parent): - super().validate(value, parent) - if sorted(value.keys()) != ["x", "y"]: - raise ValueError(f"{value} does not contain expected 'x' and 'y' keys.") + super().__init__(String(), Coordinate(), required_keys={"x", "y"}, exact_keys=True) def __init__(self): options = ( From 201aa36d20a450f38ce48775c25cf5d97ed23a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 15 Aug 2023 17:11:06 +0200 Subject: [PATCH 09/50] Tested and fixed region export and import. Added functions for deleting a region and clearing all regions. --- carta/region.py | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/carta/region.py b/carta/region.py index 29a199d..c6617c1 100644 --- a/carta/region.py +++ b/carta/region.py @@ -11,7 +11,20 @@ class RegionSet(BasePathMixin): - """Utility object for collecting region-related image functions.""" + """Utility object for collecting region-related image functions. + + Parameters + ---------- + image : :obj:`carta.image.Image` object + The image associated with this region set. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image associated with this region set. + session : :obj:`carta.session.Session` object + The session object associated with this region set. + """ def __init__(self, image): self.image = image @@ -43,6 +56,7 @@ def import_from_file(self, path): If the file does not exist or is not a region file. """ directory, file_name = posixpath.split(path) + directory = self.session.resolve_file_path(directory) file_type = FileType(self.session.call_action("backendService.getRegionFileInfo", directory, file_name, return_path="fileInfo.type")) @@ -64,9 +78,10 @@ def export_to_file(self, path, coordinate_type=CoordinateType.WORLD, file_type=F The region IDs to include. By default all regions will be included (except the cursor). """ directory, file_name = posixpath.split(path) + directory = self.session.resolve_file_path(directory) if region_ids is None: - region_ids = self.get_value("regionIds")[1:] + region_ids = [r["id"] for r in self.get_value("regionList")[1:]] self.session.call_action("exportRegions", directory, file_name, coordinate_type, file_type, region_ids, self.image._frame) @@ -136,11 +151,32 @@ def add_compass(self, center, length, rotation=0, name=""): def add_ruler(self, start, end, rotation=0, name=""): return self.add_region(RegionType.ANNRULER, [start, end], rotation, name) + def clear(self): + """Delete all regions except for the cursor region.""" + for region in self.list()[1:]: + region.delete() + class Region(BasePathMixin): """Utility object which provides access to one region associated with an image. # TODO find out what happens to region IDs when you match/unmatch or delete. + + Parameters + ---------- + region_set : :obj:`carta.region.RegionSet` object + The region set containing this region. + region_id : integer + The ID of this region. + + Attributes + ---------- + region_set : :obj:`carta.region.RegionSet` object + The region set containing this region. + region_id : integer + The ID of this region. + session : :obj:`carta.session.Session` object + The session object associated with this region. """ REGION_TYPE = None @@ -185,6 +221,10 @@ def from_list(cls, region_set, region_list): def region_type(self): return RegionType(self.get_value("regionType")) + def delete(self): + """Delete this region.""" + self.region_set.call_action("deleteRegion", self._region) + class Annotation(Region): """Base class for annotations.""" From 5ec4720714e83d95b453a87503704dd0dcf07e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Wed, 16 Aug 2023 12:33:32 +0200 Subject: [PATCH 10/50] fixed ls test; fixed add_* validation; removed rotation from most add_* functions --- carta/region.py | 106 ++++++++++++++++++++++++++++++------------ tests/test_session.py | 7 ++- 2 files changed, 79 insertions(+), 34 deletions(-) diff --git a/carta/region.py b/carta/region.py index c6617c1..cdc2ebe 100644 --- a/carta/region.py +++ b/carta/region.py @@ -5,7 +5,7 @@ import posixpath -from .util import Macro, BasePathMixin, Point as Pt, cached +from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse from .constants import FileType, RegionType, CoordinateType from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf @@ -41,8 +41,16 @@ def list(self): region_list = self.get_value("regionList") return Region.from_list(self, region_list) + @validate(Number()) + def get(self, region_id): + try: + region_type = self.get_value(f"regionMap[{region_id}]", return_path="regionType") + except CartaBadResponse: + raise ValueError(f"Could not find region with ID {region_id}.") + return Region.existing(region_type, self, region_id) + @validate(String()) - def import_from_file(self, path): + def import_from(self, path): """Import regions into this image from a file. Parameters @@ -63,7 +71,7 @@ def import_from_file(self, path): self.session.call_action("importRegion", directory, file_name, file_type, self.image._frame) @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG), NoneOr(IterableOf(Number()))) - def export_to_file(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF, region_ids=None): + def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF, region_ids=None): """Export regions from this image into a file. Parameters @@ -109,47 +117,48 @@ def add_point(self, center, annotation=False, name=""): region_type = RegionType.ANNPOINT if annotation else RegionType.POINT return self.add_region(region_type, [center], name=name) - @validate(Point(), Number(), Number(), Boolean(), String()) + @validate(Point(), Number(), Number(), Boolean(), Number(), String()) def add_rectangle(self, center, width, height, annotation=False, rotation=0, name=""): region_type = RegionType.ANNRECTANGLE if annotation else RegionType.RECTANGLE return self.add_region(region_type, [center, [width, height]], rotation, name) - @validate(Point(), Number(), Number(), Boolean(), String()) + @validate(Point(), Number(), Number(), Boolean(), Number(), String()) def add_ellipse(self, center, semi_major, semi_minor, annotation=False, rotation=0, name=""): region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE return self.add_region(region_type, [center, [semi_major, semi_minor]], rotation, name) - @validate(IterableOf(Point()), Boolean(), Number(), String()) - def add_polygon(self, points, annotation=False, rotation=0, name=""): + @validate(IterableOf(Point()), Boolean(), String()) + def add_polygon(self, points, annotation=False, name=""): region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON - return self.add_region(region_type, points, rotation, name) + return self.add_region(region_type, points, name=name) - @validate(Point(), Point(), Boolean(), Number(), String()) - def add_line(self, start, end, annotation=False, rotation=0, name=""): + @validate(Point(), Point(), Boolean(), String()) + def add_line(self, start, end, annotation=False, name=""): region_type = RegionType.ANNLINE if annotation else RegionType.LINE - return self.add_region(region_type, [start, end], rotation, name) + return self.add_region(region_type, [start, end], name=name) - @validate(IterableOf(Point()), Boolean(), Number(), String()) - def add_polyline(self, points, annotation=False, rotation=0, name=""): + @validate(IterableOf(Point()), Boolean(), String()) + def add_polyline(self, points, annotation=False, name=""): region_type = RegionType.ANNPOLYLINE if annotation else RegionType.POLYLINE - return self.add_region(region_type, points, rotation, name) + return self.add_region(region_type, points, name=name) - @validate(IterableOf(Point()), Number(), String()) - def add_vector(self, points, rotation=0, name=""): - return self.add_region(RegionType.ANNVECTOR, points, rotation, name) + @validate(Point(), Point(), String()) + def add_vector(self, start, end, name=""): + return self.add_region(RegionType.ANNVECTOR, [start, end], name=name) - @validate(Point(), Number(), Number(), String()) - def add_text(self, center, width, height, rotation=0, name=""): - # TODO where is the text set? We should do that in one step. - return self.add_region(RegionType.ANNTEXT, [center, [width, height]], rotation, name) + @validate(Point(), Number(), Number(), String(), Number(), String()) + def add_text(self, center, width, height, text, rotation=0, name=""): + region = self.add_region(RegionType.ANNTEXT, [center, [width, height]], rotation, name) + region.set_text(text) + return region - @validate(Point(), Number(), Number(), String()) - def add_compass(self, center, length, rotation=0, name=""): - return self.add_region(RegionType.ANNCOMPASS, [center, [length, length]], rotation, name) + @validate(Point(), Number(), String()) + def add_compass(self, center, length, name=""): + return self.add_region(RegionType.ANNCOMPASS, [center, [length, length]], name=name) - @validate(Point(), Point(), Number(), String()) - def add_ruler(self, start, end, rotation=0, name=""): - return self.add_region(RegionType.ANNRULER, [start, end], rotation, name) + @validate(Point(), Point(), String()) + def add_ruler(self, start, end, name=""): + return self.add_region(RegionType.ANNRULER, [start, end], name=name) def clear(self): """Delete all regions except for the cursor region.""" @@ -202,25 +211,59 @@ def __repr__(self): @classmethod @validate(Constant(RegionType)) def region_class(cls, region_type): - return cls.CUSTOM_CLASS.get(region_type, Annotation if region_type.is_annotation else Region) + return cls.CUSTOM_CLASS.get(RegionType(region_type), Annotation if region_type.is_annotation else Region) + + @classmethod + @validate(Constant(RegionType), InstanceOf(RegionSet), Number()) + def existing(cls, region_type, region_set, region_id): + return cls.region_class(region_type)(region_set, region_id) @classmethod @validate(InstanceOf(RegionSet), Constant(RegionType), IterableOf(Point()), Number(), String()) def new(cls, region_set, region_type, points, rotation=0, name=""): points = [Pt.from_object(point) for point in points] region_id = region_set.call_action("addRegionAsync", region_type, points, rotation, name, return_path="regionId") - return cls.region_class(region_type)(region_set, region_id) + return cls.existing(region_type, region_set, region_id) @classmethod @validate(InstanceOf(RegionSet), IterableOf(MapOf(String(), Number(), required_keys={"type", "id"}))) def from_list(cls, region_set, region_list): - return [cls.region_class(RegionType(r["type"]))(region_set, r["id"]) for r in region_list] + return [cls.existing(r["type"], region_set, r["id"]) for r in region_list] @property @cached def region_type(self): return RegionType(self.get_value("regionType")) + @validate(Point()) + def set_center(self, center): + self.call_action("setCenter", Pt.from_object(center)) + + @validate(Point()) + def set_size(self, size): + self.call_action("setSize", Pt.from_object(size)) + + # def lock(self): + # pass + + # def set_focus(self): + # pass + + @validate(Number()) + def set_rotation(self, angle): + """Set the rotation of this region to the given angle. + + Parameters + ---------- + angle : {0} + The new rotation angle. + """ + self.call_action("setRotation", angle) + + @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG)) + def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF): + self.region_set.export_to(path, coordinate_type, file_type, [self.region_id]) + def delete(self): """Delete this region.""" self.region_set.call_action("deleteRegion", self._region) @@ -238,6 +281,9 @@ class PointAnnotation(Annotation): class TextAnnotation(Annotation): REGION_TYPE = RegionType.ANNTEXT + def set_text(self, text): + self.call_action("setText", text) + class VectorAnnotation(Annotation): REGION_TYPE = RegionType.ANNVECTOR diff --git a/tests/test_session.py b/tests/test_session.py index b0d1c85..584bd14 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -95,12 +95,11 @@ def test_pwd(session, mock_call_action, mock_get_value): assert pwd == "/current/dir" -def test_ls(session, mock_method, mock_call_action, mock_get_value): +def test_ls(session, mock_method, mock_call_action): mock_method("pwd", ["/current/dir"]) - mock_get_value.side_effect = [{"files": [{"name": "foo.fits"}, {"name": "bar.fits"}], "subdirectories": [{"name": "baz"}]}] + mock_call_action.side_effect = [{"files": [{"name": "foo.fits"}, {"name": "bar.fits"}], "subdirectories": [{"name": "baz"}]}] ls = session.ls() - mock_call_action.assert_called_with("fileBrowserStore.getFileList", "/current/dir") - mock_get_value.assert_called_with("fileBrowserStore.fileList") + mock_call_action.assert_called_with("backendService.getFileList", "/current/dir", 2) assert ls == ["bar.fits", "baz/", "foo.fits"] From 59b1c470fa4bd044396cdb5e48dc3ef0b1e69b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Wed, 16 Aug 2023 20:40:27 +0200 Subject: [PATCH 11/50] Added more region properties --- carta/constants.py | 40 +++++++ carta/region.py | 264 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 294 insertions(+), 10 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index ab38f9a..b6076ca 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -227,3 +227,43 @@ class CoordinateType(IntEnum): """Coordinate types corresponding to the protobuf enum.""" PIXEL = 0 WORLD = 1 + + +class PointShape(IntEnum): + """Point annotation shapes corresponding to the protobuf enum.""" + SQUARE = 0 + BOX = 1 + CIRCLE = 2 + CIRCLE_LINED = 3 + DIAMOND = 4 + DIAMOND_LINED = 5 + CROSS = 6 + X = 7 + + +class TextPosition(IntEnum): + """Text annotation positions corresponding to the protobuf enum.""" + CENTER = 0 + UPPER_LEFT = 1 + UPPER_RIGHT = 2 + LOWER_LEFT = 3 + LOWER_RIGHT = 4 + TOP = 5 + BOTTOM = 6 + LEFT = 7 + RIGHT = 8 + + +class AnnotationFontStyle(StrEnum): + """Font styles which may be used in annotations.""" + NORMAL = "Normal" + BOLD = "Bold" + ITALIC = "Italic" + BOLD_ITALIC = "Italic Bold" + + +class AnnotationFont(StrEnum): + """Fonts which may be used in annotations.""" + HELVETICA = "Helvetica" + TIMES = "Times" + COURIER = "Courier" diff --git a/carta/region.py b/carta/region.py index cdc2ebe..2efde0e 100644 --- a/carta/region.py +++ b/carta/region.py @@ -6,8 +6,8 @@ import posixpath from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse -from .constants import FileType, RegionType, CoordinateType -from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf +from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont +from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional class RegionSet(BasePathMixin): @@ -208,6 +208,8 @@ def __init__(self, region_set, region_id): def __repr__(self): return f"{self.region_id}:{self.region_type.label}" + # CREATE OR CONNECT + @classmethod @validate(Constant(RegionType)) def region_class(cls, region_type): @@ -230,11 +232,51 @@ def new(cls, region_set, region_type, points, rotation=0, name=""): def from_list(cls, region_set, region_list): return [cls.existing(r["type"], region_set, r["id"]) for r in region_list] + # GET PROPERTIES + @property @cached def region_type(self): return RegionType(self.get_value("regionType")) + @property + def center(self): + return Pt.from_object(self.get_value("center")) + + @property + def size(self): + return Pt.from_object(self.get_value("size")) + + @property + def wcs_size(self): + return Pt.from_object(self.get_value("wcsSize")) # TODO use WCS Point once implemented + + @property + def rotation(self): + return self.get_value("rotation") + + @property + def control_points(self): + return [Pt.from_object(p) for p in self.get_value("controlPoints")] + + @property + def name(self): + return self.get_value("name") + + @property + def color(self): + return self.get_value("color") + + @property + def line_width(self): + return self.get_value("lineWidth") + + @property + def dash_length(self): + return self.get_value("dashLength") + + # SET PROPERTIES + @validate(Point()) def set_center(self, center): self.call_action("setCenter", Pt.from_object(center)) @@ -243,11 +285,13 @@ def set_center(self, center): def set_size(self, size): self.call_action("setSize", Pt.from_object(size)) - # def lock(self): - # pass + @validate(Point()) + def set_control_point(self, index, point): + self.call_action("setControlPoint", index, Pt.from_object(point)) - # def set_focus(self): - # pass + @validate(IterableOf(Point())) + def set_control_points(self, points): + self.call_action("setControlPoints", [Pt.from_object(p) for p in points]) @validate(Number()) def set_rotation(self, angle): @@ -260,6 +304,33 @@ def set_rotation(self, angle): """ self.call_action("setRotation", angle) + @validate(String()) + def set_name(self, name): + self.call_action("setName", name) + + @validate(Color()) + def set_color(self, color): + self.call_action("setColor", color) + + @validate(Number()) + def set_line_width(self, width): + self.call_action("setLineWidth", width) + + @validate(Number()) + def set_dash_length(self, length): + self.call_action("setDashLength", length) + + def lock(self): + self.call_action("setLocked", True) + + def unlock(self): + self.call_action("setLocked", False) + + def focus(self): + self.call_action("focusCenter") + + # IMPORT AND EXPORT + @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG)) def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF): self.region_set.export_to(path, coordinate_type, file_type, [self.region_id]) @@ -274,24 +345,197 @@ class Annotation(Region): pass +# TODO this may be general enough to live somewhere else +# TODO maybe consolidate these into single functions +class HasFontMixin: + + # GET PROPERTIES + + @property + def font_size(self): + return self.get_value("fontSize") + + @property + def font_style(self): + return AnnotationFontStyle(self.get_value("fontStyle")) + + @property + def font(self): + return AnnotationFont(self.get_value("font")) + + # SET PROPERTIES + + @validate(Number()) + def set_font_size(self, size): + self.call_action("setFontSize", size) + + @validate(Constant(AnnotationFontStyle)) + def set_font_style(self, style): + self.call_action("setFontStyle", style) + + @validate(Constant(AnnotationFont)) + def set_font(self, font): + self.call_action("setFont", font) + + +# TODO maybe consolidate these into single functions +class HasPointerMixin: + + # GET PROPERTIES + + @property + def pointer_width(self): + return self.get_value("pointerWidth") + + @property + def pointer_length(self): + return self.get_value("pointerLength") + + # SET PROPERTIES + + @validate(Number()) + def set_pointer_width(self, width): + self.call_action("setPointerWidth", width) + + @validate(Number()) + def set_pointer_length(self, length): + self.call_action("setPointerLength", length) + + class PointAnnotation(Annotation): REGION_TYPE = RegionType.ANNPOINT + # GET PROPERTIES + + @property + def point_shape(self): + return PointShape(self.get_value("pointShape")) + + @property + def point_width(self): + return self.get_value("pointWidth") -class TextAnnotation(Annotation): + # SET PROPERTIES + + @validate(Constant(PointShape)) + def set_point_shape(self, shape): + self.call_action("setPointShape", shape) + + @validate(Number()) + def set_point_width(self, width): + self.call_action("setPointWidth", width) + + +class TextAnnotation(Annotation, HasFontMixin): REGION_TYPE = RegionType.ANNTEXT + # GET PROPERTIES + + @property + def text(self): + return self.get_value("text") + + @property + def position(self): + return TextPosition(self.get_value("position")) + + # SET PROPERTIES + + @validate(String()) def set_text(self, text): self.call_action("setText", text) + @validate(Constant(TextPosition)) + def set_position(self, position): + self.call_action("setPosition", position) -class VectorAnnotation(Annotation): + +class VectorAnnotation(Annotation, HasPointerMixin): REGION_TYPE = RegionType.ANNVECTOR -class CompassAnnotation(Annotation): +class CompassAnnotation(Annotation, HasFontMixin, HasPointerMixin): REGION_TYPE = RegionType.ANNCOMPASS + # GET PROPERTIES + + @property + def labels(self): + return self.get_value("northLabel"), self.get_value("eastLabel") + + @property + def length(self): + return self.get_value("length") + + @property + def text_offsets(self): + return Pt.from_object(self.get_value("northTextOffset")), Pt.from_object(self.get_value("eastTextOffset")) -class RulerAnnotation(Annotation): + @property + def arrowheads_visible(self): + return self.get_value("northArrowhead"), self.get_value("eastArrowhead") + + # SET PROPERTIES + + @validate(*all_optional(String(), String())) + def set_label(self, north_label=None, east_label=None): + if north_label is not None: + self.call_action("setLabel", north_label, True) + if east_label is not None: + self.call_action("setLabel", east_label, False) + + @validate(Number()) + def set_length(self, length): + self.call_action("setLength", length) + + @validate(*all_optional(Point(), Point())) + def set_text_offset(self, north_offset=None, east_offset=None): + if north_offset is not None: + north_offset = Pt.from_object(north_offset) + self.call_action("setNorthTextOffset", north_offset.x, True) + self.call_action("setNorthTextOffset", north_offset.y, False) + if east_offset is not None: + east_offset = Pt.from_object(east_offset) + self.call_action("setEastTextOffset", east_offset.x, True) + self.call_action("setEastTextOffset", east_offset.y, False) + + @validate(*all_optional(Boolean(), Boolean())) + def set_arrowhead_visible(self, north=None, east=None): + if north is not None: + self.call_action("setNorthArrowhead", north) + if east is not None: + self.call_action("setEastArrowhead", east) + + +class RulerAnnotation(Annotation, HasFontMixin): REGION_TYPE = RegionType.ANNRULER + + # GET PROPERTIES + + @property + def auxiliary_lines_visible(self): + return self.get_value("auxiliaryLineVisible") + + @property + def auxiliary_lines_dash_length(self): + return self.get_value("auxiliaryLineDashLength") + + @property + def text_offset(self): + return Pt.from_object(self.get_value("textOffset")) + + # SET PROPERTIES + + @validate(Boolean()) + def set_auxiliary_lines_visible(self, visible): + self.call_action("setAuxiliaryLineVisible", visible) + + @validate(Number()) + def set_auxiliary_lines_dash_length(self, length): + self.call_action("setAuxiliaryLineDashLength", length) + + @validate(Point()) + def set_text_offset(self, offset): + offset = Pt.from_object(offset) + self.call_action("setTextOffset", offset.x, True) + self.call_action("setTextOffset", offset.y, False) From 2fb783b98c691aab0ad0f4d46473d9ce8e6aa840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 17 Aug 2023 00:47:40 +0200 Subject: [PATCH 12/50] Removed string representation of pixel units. Bare numbers are now interpreted as pixels. Removed option to set coordinate system in set_center; this must now be done separately. Updated docstrings and tests. Number validator no longer matches numeric strings. --- carta/image.py | 43 ++++++++++++++------------------ carta/units.py | 52 -------------------------------------- carta/validation.py | 22 ++++------------ tests/test_image.py | 25 ++++++------------- tests/test_units.py | 54 +++------------------------------------- tests/test_validation.py | 6 ++--- 6 files changed, 36 insertions(+), 166 deletions(-) diff --git a/carta/image.py b/carta/image.py index 7ebd233..21fb9f1 100644 --- a/carta/image.py +++ b/carta/image.py @@ -2,9 +2,9 @@ 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, CoordinateSystem, SpatialAxis +from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, SpatialAxis from .util import Macro, cached -from .units import PixelValue, AngularSize, WorldCoordinate +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 @@ -390,11 +390,13 @@ def valid_wcs(self): """ return self.get_value("validWcs") - @validate(Coordinate(), Coordinate(), NoneOr(Constant(CoordinateSystem))) - def set_center(self, x, y, system=None): - """Set the center position, in image or world coordinates. Optionally change the session-wide coordinate system. + @validate(Coordinate(), Coordinate()) + def set_center(self, x, y): + """Set the center position, in image or world coordinates. - Coordinates must either both be image coordinates or match the current number formats. Numbers and numeric strings with no units are interpreted as degrees. + 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 ---------- @@ -402,25 +404,18 @@ def set_center(self, x, y, system=None): The X position. y : {1} The Y position. - system : {2} - The coordinate system. If this parameter is provided, the coordinate system will be changed session-wide before the X and Y coordinates are parsed. 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 format. + 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. """ - if system is not None: - self.session.set_coordinate_system(system) - - x_is_pixel = PixelValue.valid(str(x)) - y_is_pixel = PixelValue.valid(str(y)) + x_is_pixel = isinstance(x, (int, float)) + y_is_pixel = isinstance(y, (int, float)) if x_is_pixel and y_is_pixel: # Image coordinates - x_value = PixelValue.as_float(str(x)) - y_value = PixelValue.as_float(str(y)) - self.call_action("setCenter", x_value, y_value) + self.call_action("setCenter", x, y) elif x_is_pixel or y_is_pixel: raise ValueError("Cannot mix image and world coordinates.") @@ -430,15 +425,15 @@ def set_center(self, x, y, system=None): raise ValueError("Cannot parse world coordinates. This image does not contain valid WCS information. Please use image coordinates (in pixels) instead.") number_format_x, number_format_y, _ = self.session.number_format() - x_value = WorldCoordinate.with_format(number_format_x).from_string(str(x), SpatialAxis.X) - y_value = WorldCoordinate.with_format(number_format_y).from_string(str(y), SpatialAxis.Y) + x_value = WorldCoordinate.with_format(number_format_x).from_string(x, SpatialAxis.X) + y_value = WorldCoordinate.with_format(number_format_y).from_string(y, SpatialAxis.Y) self.call_action("setCenterWcs", str(x_value), str(y_value)) @validate(Size(), Constant(SpatialAxis)) def zoom_to_size(self, size, axis): """Zoom to the given size along the specified axis. - Numbers and numeric strings with no units are interpreted as arcseconds. + Numbers are interpreted as pixel sizes. Numeric strings with no units are interpreted as arcseconds. Parameters ---------- @@ -450,12 +445,10 @@ def zoom_to_size(self, size, axis): Raises ------ ValueError - If world coordinates are provided and the image has no valid WCS information. + If an angular size is provided and the image has no valid WCS information. """ - size = str(size) - - if PixelValue.valid(size): - self.call_action(f"zoomToSize{axis.upper()}", PixelValue.as_float(size)) + if isinstance(size, (int, float)): + self.call_action(f"zoomToSize{axis.upper()}", size) else: if not self.valid_wcs: raise ValueError("Cannot parse angular size. This image does not contain valid WCS information. Please use a pixel size instead.") diff --git a/carta/units.py b/carta/units.py index 7e908df..e6e98f9 100644 --- a/carta/units.py +++ b/carta/units.py @@ -6,58 +6,6 @@ from .constants import NumberFormat, SpatialAxis -class PixelValue: - """Parses pixel values.""" - - UNITS = {"px", "pix", "pixel", "pixels"} - UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)\s*(?:{'|'.join(UNITS)})$" - - @classmethod - def valid(cls, value): - """Whether the input string is a numeric value followed by a pixel unit. - - Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. Pixel values may be negative. - - Parameters - ---------- - value : string - The input string. - - Returns - ------- - boolean - Whether the input string is a pixel value. - """ - m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) - return m is not None - - @classmethod - def as_float(cls, value): - """Parse a string containing a numeric value followed by a pixel unit, and return the numeric part as a float. - - Permitted pixel unit strings are stored in :obj:`carta.util.PixelValue.UNITS`. Whitespace is permitted after the number and before the unit. - - Parameters - ---------- - value : string - The string representation of the pixel value. - - Returns - ------- - float - The numeric portion of the pixel value. - - Raises - ------ - ValueError - If the input string is not in a recognized format. - """ - m = re.match(cls.UNIT_REGEX, value, re.IGNORECASE) - if m is None: - raise ValueError(f"{repr(value)} is not in a recognized pixel format.") - return float(m.group(1)) - - class AngularSize: """An angular size. diff --git a/carta/validation.py b/carta/validation.py index b74eea2..3a0c47a 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -5,7 +5,7 @@ import inspect from .util import CartaValidationFailed -from .units import PixelValue, AngularSize, WorldCoordinate +from .units import AngularSize, WorldCoordinate class Parameter: @@ -195,9 +195,7 @@ def validate(self, value, parent): See :obj:`carta.validation.Parameter.validate` for general information about this method. """ - try: - float(value) # TODO: this will allow strings and probably other types, but they will fail below. Coerce to float?? - except TypeError: + if not isinstance(value, (int, float)): raise TypeError(f"{value} has type {type(value)} but a number was expected.") if self.min is not None: @@ -676,15 +674,7 @@ def __init__(self): class Size(Union): - """A representation of an angular size or a size in pixels. Can be a number or a numeric string with valid size units. Validates strings using :obj:`carta.util.PixelValue` and :obj:`carta.util.AngularSize`.""" - - class PixelValue(String): - """Helper validator class which uses :obj:`carta.util.PixelValue` to validate strings.""" - - def validate(self, value, parent): - super().validate(value, parent) - if not PixelValue.valid(value): - raise ValueError(f"{value} is not a pixel value.") + """A representation of an angular size or a size in pixels. Can be a number or a numeric string with valid size units. A number is assumed to be a pixel value. Validates strings using :obj:`carta.util.AngularSize`.""" class AngularSize(String): """Helper validator class which uses :obj:`carta.util.AngularSize` to validate strings.""" @@ -697,14 +687,13 @@ def validate(self, value, parent): def __init__(self): options = ( Number(), - self.PixelValue(), self.AngularSize(), ) super().__init__(*options, description="a number or a numeric string with valid size units") class Coordinate(Union): - """A string representation of a world coordinate or image coordinate. Can be a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units. Validates strings using :obj:`carta.util.PixelValue` and :obj:`carta.util.WorldCoordinate`.""" + """A representation of a world coordinate or image coordinate. Can be a number, a string in H:M:S or D:M:S format, or a numeric string with degree units. A number is assumed to be a pixel value. Validates strings using :obj:`carta.util.WorldCoordinate`.""" class WorldCoordinate(String): """Helper validator class which uses :obj:`carta.util.WorldCoordinate` to validate strings.""" @@ -717,10 +706,9 @@ def validate(self, value, parent): def __init__(self): options = ( Number(), - Size.PixelValue(), self.WorldCoordinate(), ) - super().__init__(*options, description="a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units") + super().__init__(*options, description="a number, a string in H:M:S or D:M:S format, or a numeric string with degree units") class Attr(str): diff --git a/tests/test_image.py b/tests/test_image.py index 1d0b11d..1f04c4d 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -4,7 +4,7 @@ from carta.session import Session from carta.image import Image from carta.util import CartaValidationFailed -from carta.constants import NumberFormat as NF, CoordinateSystem, SpatialAxis as SA +from carta.constants import NumberFormat as NF, SpatialAxis as SA # FIXTURES @@ -179,13 +179,12 @@ def test_set_center_valid_pixels(image, mock_property, mock_call_action, x, y): mock_property("width", 20) mock_property("height", 20) - image.set_center(f"{x}px", f"{y}px") - mock_call_action.assert_called_with("setCenter", float(x), float(y)) + image.set_center(x, y) + mock_call_action.assert_called_with("setCenter", x, y) @pytest.mark.parametrize("x,y,x_fmt,y_fmt,x_norm,y_norm", [ ("123", "12", NF.DEGREES, NF.DEGREES, "123", "12"), - (123, 12, NF.DEGREES, NF.DEGREES, "123", "12"), ("123deg", "12 deg", NF.DEGREES, NF.DEGREES, "123", "12"), ("12:34:56.789", "12:34:56.789", NF.HMS, NF.DMS, "12:34:56.789", "12:34:56.789"), ("12h34m56.789s", "12d34m56.789s", NF.HMS, NF.DMS, "12:34:56.789", "12:34:56.789"), @@ -200,24 +199,13 @@ def test_set_center_valid_wcs(image, mock_property, mock_session_method, mock_ca mock_call_action.assert_called_with("setCenterWcs", x_norm, y_norm) -def test_set_center_valid_change_system(image, mock_property, mock_session_method, mock_call_action, mock_session_call_action): - mock_property("valid_wcs", True) - mock_session_method("number_format", [(NF.DEGREES, NF.DEGREES, None)]) - - image.set_center("123", "12", CoordinateSystem.GALACTIC) - - # We're not testing if this system has the correct format; just that the function is called - mock_session_call_action.assert_called_with("overlayStore.global.setSystem", CoordinateSystem.GALACTIC) - mock_call_action.assert_called_with("setCenterWcs", "123", "12") - - @pytest.mark.parametrize("x,y,wcs,x_fmt,y_fmt,error_contains", [ ("abc", "def", True, NF.DEGREES, NF.DEGREES, "Invalid function parameter"), ("123", "123", False, NF.DEGREES, NF.DEGREES, "does not contain valid WCS information"), ("123", "123", True, NF.HMS, NF.DMS, "does not match expected format"), ("123", "123", True, NF.DEGREES, NF.DMS, "does not match expected format"), - ("123px", "123", True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), - ("123", "123px", True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), + (123, "123", True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), + ("123", 123, True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), ]) def test_set_center_invalid(image, mock_property, mock_session_method, mock_call_action, x, y, wcs, x_fmt, y_fmt, error_contains): mock_property("width", 200) @@ -232,7 +220,7 @@ def test_set_center_invalid(image, mock_property, mock_session_method, mock_call @pytest.mark.parametrize("axis", [SA.X, SA.Y]) @pytest.mark.parametrize("val,action,norm", [ - ("123px", "zoomToSize{0}", 123.0), + (123, "zoomToSize{0}", 123.0), ("123arcsec", "zoomToSize{0}Wcs", "123\""), ("123\"", "zoomToSize{0}Wcs", "123\""), ("123", "zoomToSize{0}Wcs", "123\""), @@ -248,6 +236,7 @@ def test_zoom_to_size(image, mock_property, mock_call_action, axis, val, action, @pytest.mark.parametrize("axis", [SA.X, SA.Y]) @pytest.mark.parametrize("val,wcs,error_contains", [ + ("123px", True, "Invalid function parameter"), ("abc", True, "Invalid function parameter"), ("123arcsec", False, "does not contain valid WCS information"), ]) diff --git a/tests/test_units.py b/tests/test_units.py index 34b62f7..a004649 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -1,11 +1,11 @@ import types import pytest -from carta.units import PixelValue, AngularSize, DegreesSize, ArcminSize, ArcsecSize, MilliarcsecSize, MicroarcsecSize, WorldCoordinate, DegreesCoordinate, HMSCoordinate, DMSCoordinate +from carta.units import AngularSize, DegreesSize, ArcminSize, ArcsecSize, MilliarcsecSize, MicroarcsecSize, WorldCoordinate, DegreesCoordinate, HMSCoordinate, DMSCoordinate from carta.constants import NumberFormat as NF, SpatialAxis as SA -@pytest.mark.parametrize("clazz", [PixelValue, AngularSize, WorldCoordinate]) +@pytest.mark.parametrize("clazz", [AngularSize, WorldCoordinate]) def test_class_has_docstring(clazz): assert clazz.__doc__ is not None @@ -17,59 +17,11 @@ def find_members(*classes, member_type=types.MethodType): yield getattr(clazz, name) -@pytest.mark.parametrize("member", find_members(PixelValue, AngularSize, WorldCoordinate)) +@pytest.mark.parametrize("member", find_members(AngularSize, WorldCoordinate)) def test_class_classmethods_have_docstrings(member): assert member.__doc__ is not None -@pytest.mark.parametrize("value,valid", [ - ("123px", True), - ("123.4px", True), - ("123pix", True), - ("123pixel", True), - ("123pixels", True), - ("123 px", True), - ("123 pix", True), - ("123 pixel", True), - ("123 pixels", True), - ("-123px", True), - ("-123.4px", True), - - ("123arcmin", False), - ("123deg", False), - ("abc", False), - ("123", False), - ("123abc", False), -]) -def test_pixel_value_valid(value, valid): - assert PixelValue.valid(value) == valid - - -@pytest.mark.parametrize("value,num", [ - ("123px", 123), - ("123pix", 123), - ("123pixel", 123), - ("123pixels", 123), - ("123 px", 123), - ("123 pix", 123), - ("123 pixel", 123), - ("123 pixels", 123), - ("123.45px", 123.45), - ("123.45 px", 123.45), - ("-123.45px", -123.45), - ("-123.45 px", -123.45), -]) -def test_pixel_value_as_float(value, num): - assert PixelValue.as_float(value) == num - - -@pytest.mark.parametrize("value", ["123arcmin", "123deg", "abc", "123", "123abc"]) -def test_pixel_value_as_float_invalid(value): - with pytest.raises(ValueError) as e: - PixelValue.as_float(value) - assert "not in a recognized pixel format" in str(e.value) - - @pytest.mark.parametrize("size,valid", [ ("123deg", True), ("123degree", True), diff --git a/tests/test_validation.py b/tests/test_validation.py index 7b3e1e0..3cbe650 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -3,13 +3,13 @@ from carta.validation import Size, Coordinate -@pytest.mark.parametrize('val', [123, "123arcmin", "123arcsec", "123deg", "123degree", "123degrees", "123px", "123pix", "123pixel", "123pixels", "123 arcmin", "123 arcsec", "123 deg", "123 degree", "123 degrees", "123 px", "123 pix", "123 pixel", "123 pixels", "123", "123\"", "123'"]) +@pytest.mark.parametrize('val', [123, "123arcmin", "123arcsec", "123deg", "123degree", "123degrees", "123 arcmin", "123 arcsec", "123 deg", "123 degree", "123 degrees", "123", "123\"", "123'"]) def test_size_valid(val): v = Size() v.validate(val, None) -@pytest.mark.parametrize('val', ["123abc", "abc", "123 \"", "123 '", ""]) +@pytest.mark.parametrize('val', ["123abc", "abc", "123 \"", "123 '", "", "123 px", "123 pix", "123 pixel", "123 pixels", "123px", "123pix", "123pixel", "123pixels"]) def test_size_invalid(val): v = Size() with pytest.raises(ValueError) as e: @@ -28,4 +28,4 @@ def test_coordinate_invalid(val): v = Coordinate() with pytest.raises(ValueError) as e: v.validate(val, None) - assert "not a number, a string in H:M:S or D:M:S format, or a numeric string with degree units or pixel units" in str(e.value) + assert "not a number, a string in H:M:S or D:M:S format, or a numeric string with degree units" in str(e.value) From 846f712059da3edcb04f6ca34e50ceb60c966d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 18 Aug 2023 00:41:24 +0200 Subject: [PATCH 13/50] Initial WCS support (untested) --- carta/image.py | 69 ++++++++++++++++++++++++++- carta/region.py | 112 ++++++++++++++++++++++++++++---------------- carta/units.py | 17 +++++++ carta/util.py | 37 ++++++++++----- carta/validation.py | 54 +++++++++++++++++---- 5 files changed, 227 insertions(+), 62 deletions(-) diff --git a/carta/image.py b/carta/image.py index 6c57172..4305f7a 100644 --- a/carta/image.py +++ b/carta/image.py @@ -4,9 +4,9 @@ """ from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, SpatialAxis -from .util import Macro, cached, BasePathMixin +from .util import Macro, cached, BasePathMixin, Point as Pt from .units import AngularSize, WorldCoordinate -from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional +from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional, Point from .region import RegionSet from .metadata import parse_header @@ -671,6 +671,71 @@ def set_clip_percentile(self, rank): if rank not in preset_ranks: self.call_action("renderConfig.setPercentileRank", -1) # select 'custom' rank button + # COORDINATE AND SIZE CONVERSIONS + + @validate(IterableOf(Point.WorldCoordinatePoint())) + def from_world_coordinate_points(self, points): + """Convert world coordinate points to image coordinate points. + + The points must have string values which can be parsed as world coordinates using the current globally set coordinate system (and any custom number formats). + + Parameters + ---------- + points : {} + Points with string values which are valid world coordinates. + + Returns + ------- + iterable of numeric points + Points with numeric values which are image coordinates. + """ + points = [Pt(*p) for p in points] + converted_points = self.call_action("getImagePosFromWCS", points) + return [Pt(**p).as_tuple() for p in converted_points] + + @validate(Size.AngularSize(), Constant(SpatialAxis)) + def from_angular_size(self, size, axis): + """Convert angular size to pixel size. + + Parameters + ---------- + size : {0} + The angular size. + axis : {1} + The axis. + + Returns + ------- + float + The pixel size. + """ + arcsec = AngularSize.from_string(size).arcsec() + if axis == SpatialAxis.X: + return self.call_action("getImageXValueFromArcsec", arcsec) + if axis == SpatialAxis.Y: + return self.call_action("getImageYValueFromArcsec", arcsec) + + @validate(IterableOf(Point.AngularSizePoint())) + def from_angular_size_points(self, points): + """Convert angular size points to pixel size points. + + The points must have string values which can be parsed as angular sizes. + + Parameters + ---------- + points : {} + Points with string values which are valid angular sizes. + + Returns + ------- + iterable of numeric points + Points with numeric values which are pixel sizes. + """ + converted_points = [] + for x, y in points: + converted_points.append((self.from_arcsec(x, SpatialAxis.X), self.from_arcsec(y, SpatialAxis.Y))) + return converted_points + # CLOSE def close(self): diff --git a/carta/region.py b/carta/region.py index 2efde0e..07fa1e7 100644 --- a/carta/region.py +++ b/carta/region.py @@ -7,7 +7,7 @@ from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont -from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional +from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Size, Union class RegionSet(BasePathMixin): @@ -93,7 +93,7 @@ def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileTy self.session.call_action("exportRegions", directory, file_name, coordinate_type, file_type, region_ids, self.image._frame) - @validate(Constant(RegionType), IterableOf(Point()), Number(), String()) + @validate(Constant(RegionType), IterableOf(Point.NumericPoint()), Number(), String()) def add_region(self, region_type, points, rotation=0, name=""): """Add a new region to this image. @@ -104,7 +104,7 @@ def add_region(self, region_type, points, rotation=0, name=""): region_type : {0} The type of the region. points : {1} - The control points defining the region. How these values are interpreted depends on the region type. TODO: we need to convert possible world coordinates to image coordinates here. + The control points defining the region, in image coordinates. How these values are interpreted depends on the region type. rotation : {2} The rotation of the region, in degrees. name : {3} @@ -112,52 +112,79 @@ def add_region(self, region_type, points, rotation=0, name=""): """ return Region.new(self, region_type, points, rotation, name) - @validate(Point(), Boolean(), String()) + def _from_world_coordinates(self, points): + try: + points = self.image.from_world_coordinate_points(points) + except ValueError: + pass + return points + + def _from_angular_sizes(self, points): + try: + points = self.image.from_angular_size_points(points) + except ValueError: + pass + return points + + @validate(Point.CoordinatePoint(), Boolean(), String()) def add_point(self, center, annotation=False, name=""): + [center] = self._from_world_coordinates([center]) region_type = RegionType.ANNPOINT if annotation else RegionType.POINT return self.add_region(region_type, [center], name=name) - @validate(Point(), Number(), Number(), Boolean(), Number(), String()) + @validate(Point.CoordinatePoint(), Size(), Size(), Boolean(), Number(), String()) def add_rectangle(self, center, width, height, annotation=False, rotation=0, name=""): + [center] = self._from_world_coordinates([center]) + [(width, height)] = self._from_angular_sizes([(width, height)]) region_type = RegionType.ANNRECTANGLE if annotation else RegionType.RECTANGLE - return self.add_region(region_type, [center, [width, height]], rotation, name) + return self.add_region(region_type, [center, (width, height)], rotation, name) - @validate(Point(), Number(), Number(), Boolean(), Number(), String()) + @validate(Point.CoordinatePoint(), Size(), Size(), Boolean(), Number(), String()) def add_ellipse(self, center, semi_major, semi_minor, annotation=False, rotation=0, name=""): + [center] = self._from_world_coordinates([center]) + [(semi_major, semi_minor)] = self._from_angular_sizes([(semi_major, semi_minor)]) region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE - return self.add_region(region_type, [center, [semi_major, semi_minor]], rotation, name) + return self.add_region(region_type, [center, (semi_major, semi_minor)], rotation, name) - @validate(IterableOf(Point()), Boolean(), String()) + @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) def add_polygon(self, points, annotation=False, name=""): + points = self._from_world_coordinates(points) region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON return self.add_region(region_type, points, name=name) - @validate(Point(), Point(), Boolean(), String()) + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), Boolean(), String()) def add_line(self, start, end, annotation=False, name=""): + [start, end] = self._from_world_coordinates([start, end]) region_type = RegionType.ANNLINE if annotation else RegionType.LINE return self.add_region(region_type, [start, end], name=name) - @validate(IterableOf(Point()), Boolean(), String()) + @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) def add_polyline(self, points, annotation=False, name=""): + points = self._from_world_coordinates(points) region_type = RegionType.ANNPOLYLINE if annotation else RegionType.POLYLINE return self.add_region(region_type, points, name=name) - @validate(Point(), Point(), String()) + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), String()) def add_vector(self, start, end, name=""): + [start, end] = self._from_world_coordinates([start, end]) return self.add_region(RegionType.ANNVECTOR, [start, end], name=name) - @validate(Point(), Number(), Number(), String(), Number(), String()) + @validate(Point.CoordinatePoint(), Size(), Size(), String(), Number(), String()) def add_text(self, center, width, height, text, rotation=0, name=""): - region = self.add_region(RegionType.ANNTEXT, [center, [width, height]], rotation, name) + [center] = self._from_world_coordinates([center]) + [(width, height)] = self._from_angular_sizes([(width, height)]) + region = self.add_region(RegionType.ANNTEXT, [center, (width, height)], rotation, name) region.set_text(text) return region - @validate(Point(), Number(), String()) + @validate(Point.CoordinatePoint(), Number(), String()) def add_compass(self, center, length, name=""): - return self.add_region(RegionType.ANNCOMPASS, [center, [length, length]], name=name) + [center] = self._from_world_coordinates([center]) + return self.add_region(RegionType.ANNCOMPASS, [center, (length, length)], name=name) - @validate(Point(), Point(), String()) + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), String()) def add_ruler(self, start, end, name=""): + [start, end] = self._from_world_coordinates([start, end]) return self.add_region(RegionType.ANNRULER, [start, end], name=name) def clear(self): @@ -223,7 +250,7 @@ def existing(cls, region_type, region_set, region_id): @classmethod @validate(InstanceOf(RegionSet), Constant(RegionType), IterableOf(Point()), Number(), String()) def new(cls, region_set, region_type, points, rotation=0, name=""): - points = [Pt.from_object(point) for point in points] + points = [Pt.from_object(point) for point in points] # TODO at this point we should already have pixel points here region_id = region_set.call_action("addRegionAsync", region_type, points, rotation, name, return_path="regionId") return cls.existing(region_type, region_set, region_id) @@ -241,15 +268,16 @@ def region_type(self): @property def center(self): - return Pt.from_object(self.get_value("center")) + return Pt(**self.get_value("center")).as_tuple() @property def size(self): - return Pt.from_object(self.get_value("size")) + return Pt(**self.get_value("size")).as_tuple() @property def wcs_size(self): - return Pt.from_object(self.get_value("wcsSize")) # TODO use WCS Point once implemented + size = self.get_value("wcsSize") + return (f"{size['x']}\"", f"{size['y']}\"") @property def rotation(self): @@ -257,7 +285,7 @@ def rotation(self): @property def control_points(self): - return [Pt.from_object(p) for p in self.get_value("controlPoints")] + return [Pt(**p).as_tuple() for p in self.get_value("controlPoints")] @property def name(self): @@ -277,21 +305,25 @@ def dash_length(self): # SET PROPERTIES - @validate(Point()) + @validate(Point.CoordinatePoint()) def set_center(self, center): - self.call_action("setCenter", Pt.from_object(center)) + [center] = self._from_world_coordinates([center]) + self.call_action("setCenter", Pt(*center)) - @validate(Point()) - def set_size(self, size): - self.call_action("setSize", Pt.from_object(size)) + @validate(Size(), Size()) + def set_size(self, x, y): + [(x, y)] = self._from_angular_sizes([(x, y)]) + self.call_action("setSize", Pt(x, y)) - @validate(Point()) + @validate(Point.CoordinatePoint()) def set_control_point(self, index, point): - self.call_action("setControlPoint", index, Pt.from_object(point)) + [point] = self._from_world_coordinates([point]) + self.call_action("setControlPoint", index, Pt(*point)) - @validate(IterableOf(Point())) + @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint()))) def set_control_points(self, points): - self.call_action("setControlPoints", [Pt.from_object(p) for p in points]) + points = self._from_world_coordinates(points) + self.call_action("setControlPoints", [Pt(*p) for p in points]) @validate(Number()) def set_rotation(self, angle): @@ -469,7 +501,7 @@ def length(self): @property def text_offsets(self): - return Pt.from_object(self.get_value("northTextOffset")), Pt.from_object(self.get_value("eastTextOffset")) + return Pt(**self.get_value("northTextOffset")), Pt(**self.get_value("eastTextOffset")) @property def arrowheads_visible(self): @@ -488,14 +520,14 @@ def set_label(self, north_label=None, east_label=None): def set_length(self, length): self.call_action("setLength", length) - @validate(*all_optional(Point(), Point())) + @validate(*all_optional(Point.SizePoint(), Point.SizePoint())) def set_text_offset(self, north_offset=None, east_offset=None): if north_offset is not None: - north_offset = Pt.from_object(north_offset) + [north_offset] = self._from_angular_sizes([north_offset]) self.call_action("setNorthTextOffset", north_offset.x, True) self.call_action("setNorthTextOffset", north_offset.y, False) if east_offset is not None: - east_offset = Pt.from_object(east_offset) + [east_offset] = self._from_angular_sizes([east_offset]) self.call_action("setEastTextOffset", east_offset.x, True) self.call_action("setEastTextOffset", east_offset.y, False) @@ -534,8 +566,8 @@ def set_auxiliary_lines_visible(self, visible): def set_auxiliary_lines_dash_length(self, length): self.call_action("setAuxiliaryLineDashLength", length) - @validate(Point()) - def set_text_offset(self, offset): - offset = Pt.from_object(offset) - self.call_action("setTextOffset", offset.x, True) - self.call_action("setTextOffset", offset.y, False) + @validate(Size(), Size()) + def set_text_offset(self, x, y): + [(x, y)] = self._from_angular_sizes([(x, y)]) + self.call_action("setTextOffset", x, True) + self.call_action("setTextOffset", y, False) diff --git a/carta/units.py b/carta/units.py index e6e98f9..d168500 100644 --- a/carta/units.py +++ b/carta/units.py @@ -96,6 +96,18 @@ def __str__(self): value = self.value * self.FACTOR return f"{value:g}{self.OUTPUT_UNIT}" + def arcsec(self): + """The numeric value in arcseconds. + + Returns + ------- + float + The numeric value of this angular size, in arcseconds. + """ + if type(self) is AngularSize: + raise NotImplementedError() + return self.value * self.ARCSEC_FACTOR + class DegreesSize(AngularSize): """An angular size in degrees.""" @@ -103,6 +115,7 @@ class DegreesSize(AngularSize): INPUT_UNITS = {"deg", "degree", "degrees"} OUTPUT_UNIT = "deg" FACTOR = 1 + ARCSEC_FACTOR = 3600 class ArcminSize(AngularSize): @@ -111,6 +124,7 @@ class ArcminSize(AngularSize): INPUT_UNITS = {"'", "arcminutes", "arcminute", "arcmin", "amin", "′"} OUTPUT_UNIT = "'" FACTOR = 1 + ARCSEC_FACTOR = 60 class ArcsecSize(AngularSize): @@ -119,6 +133,7 @@ class ArcsecSize(AngularSize): INPUT_UNITS = {"\"", "", "arcseconds", "arcsecond", "arcsec", "asec", "″"} OUTPUT_UNIT = "\"" FACTOR = 1 + ARCSEC_FACTOR = FACTOR class MilliarcsecSize(AngularSize): @@ -127,6 +142,7 @@ class MilliarcsecSize(AngularSize): INPUT_UNITS = {"milliarcseconds", "milliarcsecond", "milliarcsec", "mas"} OUTPUT_UNIT = "\"" FACTOR = 1e-3 + ARCSEC_FACTOR = FACTOR class MicroarcsecSize(AngularSize): @@ -135,6 +151,7 @@ class MicroarcsecSize(AngularSize): INPUT_UNITS = {"microarcseconds", "microarcsecond", "microarcsec", "µas", "uas"} OUTPUT_UNIT = "\"" FACTOR = 1e-6 + ARCSEC_FACTOR = FACTOR class WorldCoordinate: diff --git a/carta/util.py b/carta/util.py index 133205a..24cebc3 100644 --- a/carta/util.py +++ b/carta/util.py @@ -5,6 +5,8 @@ import functools import re +from .units import AngularSize, WorldCoordinate + logger = logging.getLogger("carta_scripting") logger.setLevel(logging.WARN) logger.addHandler(logging.StreamHandler()) @@ -208,14 +210,20 @@ def macro(self, target, variable): class Point: - """A representation of a 2D point. + """A pair of coordinates or sizes. + + This object may have numeric or string values, and may be used to represent a pair of coordinates or a pair of sizes. + + Numbers should be interpreted as pixel values, and strings should be valid WCS coordinates or angular sizes. Different types of values should not be combined. + + Functions exposed to the user should accept pairs of points as two-item iterables, and use this object internally to ensure that the correct serialization is sent to the frontend. It is the responsibility of calling functions to ensure that the object is interpreted correctly. Parameters ---------- - x : number - The *x* coordinate of the point. - y : number - The *y* coordinate of the point. + x : number or string + The *x* value. + y : number or string + The *y* value. """ def __init__(self, x, y): @@ -223,13 +231,20 @@ def __init__(self, x, y): self.y = y @classmethod - def from_object(cls, obj): - if isinstance(obj, Point): - return obj - if isinstance(obj, dict): - return cls(**obj) - return cls(*obj) + def is_pixel(cls, x, y): + return isinstance(x, (int, float)) and isinstance(y, (int, float)) + + @classmethod + def is_wcs_coordinate(cls, x, y): + return isinstance(x, str) and isinstance(y, str) and WorldCoordinate.valid(x) and WorldCoordinate.valid(y) + + @classmethod + def is_angular_size(cls, x, y): + return isinstance(x, str) and isinstance(y, str) and AngularSize.valid(x) and AngularSize.valid(y) def json(self): """The JSON serialization of this object.""" return {"x": self.x, "y": self.y} + + def as_tuple(self): + return self.x, self.y diff --git a/carta/validation.py b/carta/validation.py index 4529e14..77f1cda 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -4,7 +4,7 @@ import functools import inspect -from .util import CartaValidationFailed, Point as Pt +from .util import CartaValidationFailed from .units import AngularSize, WorldCoordinate @@ -721,21 +721,57 @@ def __init__(self): class Point(Union): - """A representation of a 2D point, either as a :obj:`carta.util.Point` object, or a dictionary with ``'x'`` and ``'y'`` as keys and coordinate values, or an iterable with two coordinate values (which will be evaluated as ``x`` and ``y`` coordinates in order). World or image coordinates are permitted.""" + """A pair of numbers, WCS coordinate strings, or angular size strings, as an iterable with two values (which will be evaluated as ``x`` and ``y`` values in that order). Numbers will be interpreted as pixel values. - class PointDict(MapOf): - """Helper validator class for evaluating points in dictionary format.""" + More fine-grained combinations of value options (numeric only, world coordinate only, angular size only, any coordinate, any size) are provided as local classes which may also be used individually. + The two values must always match (numbers, string coordinates and string sizes can't be mixed). + """ + + class NumericPoint(Union): + def __init__(self): + options = ( + IterableOf(Number(), min_size=2, max_size=2), + ) + super().__init__(*options, description="a pair of numbers") + + class WorldCoordinatePoint(Union): + def __init__(self): + options = ( + IterableOf(Coordinate.WorldCoordinate(), min_size=2, max_size=2), + ) + super().__init__(*options, description="a pair of coordinate strings") + + class AngularSizePoint(Union): + def __init__(self): + options = ( + IterableOf(Size.AngularSize(), min_size=2, max_size=2), + ) + super().__init__(*options, description="a pair of size strings") + + class CoordinatePoint(Union): + def __init__(self): + options = ( + Point.NumericPoint(), + Point.WorldCoordinatePoint(), + ) + super().__init__(*options, description="a pair of numbers or coordinate strings") + + class SizePoint(Union): def __init__(self): - super().__init__(String(), Coordinate(), required_keys={"x", "y"}, exact_keys=True) + options = ( + Point.NumericPoint(), + Point.AngularSizePoint(), + ) + super().__init__(*options, description="a pair of numbers or size strings") def __init__(self): options = ( - InstanceOf(Pt), - IterableOf(Coordinate(), min_size=2, max_size=2), - self.PointDict(), + self.NumericPoint(), + self.WorldCoordinatePoint(), + self.AngularSizePoint(), ) - super().__init__(*options, description="a Point object, a dictionary with ``'x'`` and ``'y'`` as keys and coordinate values, or an iterable with two coordinate values") + super().__init__(*options, description="a pair of numbers, coordinate strings, or size strings") class Attr(str): From 8fdbff895d925c5885cc242954c7342b84ed7cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 28 Aug 2023 11:40:40 +0200 Subject: [PATCH 14/50] Fixed some bugs; added WCS functions for specific region types --- carta/image.py | 22 +++++++++- carta/region.py | 108 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/carta/image.py b/carta/image.py index 4305f7a..d8469be 100644 --- a/carta/image.py +++ b/carta/image.py @@ -693,6 +693,26 @@ def from_world_coordinate_points(self, points): converted_points = self.call_action("getImagePosFromWCS", points) return [Pt(**p).as_tuple() for p in converted_points] + @validate(IterableOf(Point.NumericPoint())) + def to_world_coordinate_points(self, points): + """Convert image coordinate points to world coordinate points. + + The points must be numeric. + + Parameters + ---------- + points : {} + Points with numeric values which are valid image coordinates. + + Returns + ------- + iterable of string coordinate points + Points with string values which are world coordinates. + """ + points = [Pt(*p) for p in points] + converted_points = self.call_action("getWCSFromImagePos", points) + return [Pt(**p).as_tuple() for p in converted_points] + @validate(Size.AngularSize(), Constant(SpatialAxis)) def from_angular_size(self, size, axis): """Convert angular size to pixel size. @@ -733,7 +753,7 @@ def from_angular_size_points(self, points): """ converted_points = [] for x, y in points: - converted_points.append((self.from_arcsec(x, SpatialAxis.X), self.from_arcsec(y, SpatialAxis.Y))) + converted_points.append((self.from_angular_size(x, SpatialAxis.X), self.from_angular_size(y, SpatialAxis.Y))) return converted_points # CLOSE diff --git a/carta/region.py b/carta/region.py index 07fa1e7..fa83510 100644 --- a/carta/region.py +++ b/carta/region.py @@ -5,7 +5,7 @@ import posixpath -from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse +from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse, CartaValidationFailed from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Size, Union @@ -115,14 +115,14 @@ def add_region(self, region_type, points, rotation=0, name=""): def _from_world_coordinates(self, points): try: points = self.image.from_world_coordinate_points(points) - except ValueError: + except CartaValidationFailed: pass return points def _from_angular_sizes(self, points): try: points = self.image.from_angular_size_points(points) - except ValueError: + except CartaValidationFailed: pass return points @@ -240,7 +240,8 @@ def __repr__(self): @classmethod @validate(Constant(RegionType)) def region_class(cls, region_type): - return cls.CUSTOM_CLASS.get(RegionType(region_type), Annotation if region_type.is_annotation else Region) + region_type = RegionType(region_type) + return cls.CUSTOM_CLASS.get(region_type, Annotation if region_type.is_annotation else Region) @classmethod @validate(Constant(RegionType), InstanceOf(RegionSet), Number()) @@ -250,7 +251,7 @@ def existing(cls, region_type, region_set, region_id): @classmethod @validate(InstanceOf(RegionSet), Constant(RegionType), IterableOf(Point()), Number(), String()) def new(cls, region_set, region_type, points, rotation=0, name=""): - points = [Pt.from_object(point) for point in points] # TODO at this point we should already have pixel points here + points = [Pt(*point) for point in points] # TODO at this point we should already have pixel points here region_id = region_set.call_action("addRegionAsync", region_type, points, rotation, name, return_path="regionId") return cls.existing(region_type, region_set, region_id) @@ -270,6 +271,11 @@ def region_type(self): def center(self): return Pt(**self.get_value("center")).as_tuple() + @property + def wcs_center(self): + [center] = self.region_set.image.to_world_coordinate_points([self.center]) + return center + @property def size(self): return Pt(**self.get_value("size")).as_tuple() @@ -307,22 +313,20 @@ def dash_length(self): @validate(Point.CoordinatePoint()) def set_center(self, center): - [center] = self._from_world_coordinates([center]) + [center] = self.region_set._from_world_coordinates([center]) self.call_action("setCenter", Pt(*center)) @validate(Size(), Size()) def set_size(self, x, y): - [(x, y)] = self._from_angular_sizes([(x, y)]) + [(x, y)] = self.region_set._from_angular_sizes([(x, y)]) self.call_action("setSize", Pt(x, y)) - @validate(Point.CoordinatePoint()) + @validate(Number(), Point.NumericPoint()) def set_control_point(self, index, point): - [point] = self._from_world_coordinates([point]) self.call_action("setControlPoint", index, Pt(*point)) - @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint()))) + @validate(IterableOf(Point.NumericPoint())) def set_control_points(self, points): - points = self._from_world_coordinates(points) self.call_action("setControlPoints", [Pt(*p) for p in points]) @validate(Number()) @@ -372,12 +376,42 @@ def delete(self): self.region_set.call_action("deleteRegion", self._region) -class Annotation(Region): - """Base class for annotations.""" - pass +class HasPositionsMixin: + @property + def positions(self): + return self.control_points + + @property + def wcs_positions(self): + return self.region_set.image.to_world_coordinate_points[self.control_points] + + @validate(IterableOf(Point.CoordinatePoint())) + def set_positions(self, points): + points = self.region_set._from_world_coordinates(points) + self.set_control_points(points) + + @validate(Point.CoordinatePoint()) + def set_position(self, index, point): + [point] = self.region_set._from_world_coordinates([point]) + self.set_control_point(index, point) + + +class HasEndpointsMixin: + @property + def endpoints(self): + return self.control_points + + @property + def wcs_endpoints(self): + return self.region_set.image.to_world_coordinate_points[self.control_points] + + @validate(Point.CoordinatePoint(), Point.CoordinatePoint()) + def set_endpoints(self, start, end): + [start] = self.region_set._from_world_coordinates([start]) + [end] = self.region_set._from_world_coordinates([end]) + self.set_control_points([start, end]) -# TODO this may be general enough to live somewhere else # TODO maybe consolidate these into single functions class HasFontMixin: @@ -434,6 +468,35 @@ def set_pointer_length(self, length): self.call_action("setPointerLength", length) +class Annotation(Region): + """Base class for annotations.""" + pass + + +class LineRegion(Region, HasEndpointsMixin): + REGION_TYPE = RegionType.LINE + + +class PolylineRegion(Region, HasPositionsMixin): + REGION_TYPE = RegionType.POLYLINE + + +class PolygonRegion(Region, HasPositionsMixin): + REGION_TYPE = RegionType.POLYGON + + +class LineAnnotation(Annotation, HasEndpointsMixin): + REGION_TYPE = RegionType.ANNLINE + + +class PolylineAnnotation(Annotation, HasPositionsMixin): + REGION_TYPE = RegionType.ANNPOLYLINE + + +class PolygonAnnotation(Annotation, HasPositionsMixin): + REGION_TYPE = RegionType.ANNPOLYGON + + class PointAnnotation(Annotation): REGION_TYPE = RegionType.ANNPOINT @@ -482,7 +545,7 @@ def set_position(self, position): self.call_action("setPosition", position) -class VectorAnnotation(Annotation, HasPointerMixin): +class VectorAnnotation(Annotation, HasPointerMixin, HasEndpointsMixin): REGION_TYPE = RegionType.ANNVECTOR @@ -520,14 +583,12 @@ def set_label(self, north_label=None, east_label=None): def set_length(self, length): self.call_action("setLength", length) - @validate(*all_optional(Point.SizePoint(), Point.SizePoint())) + @validate(*all_optional(Point.NumericPoint(), Point.NumericPoint())) # TODO pixel only! def set_text_offset(self, north_offset=None, east_offset=None): if north_offset is not None: - [north_offset] = self._from_angular_sizes([north_offset]) self.call_action("setNorthTextOffset", north_offset.x, True) self.call_action("setNorthTextOffset", north_offset.y, False) if east_offset is not None: - [east_offset] = self._from_angular_sizes([east_offset]) self.call_action("setEastTextOffset", east_offset.x, True) self.call_action("setEastTextOffset", east_offset.y, False) @@ -539,7 +600,7 @@ def set_arrowhead_visible(self, north=None, east=None): self.call_action("setEastArrowhead", east) -class RulerAnnotation(Annotation, HasFontMixin): +class RulerAnnotation(Annotation, HasFontMixin, HasEndpointsMixin): REGION_TYPE = RegionType.ANNRULER # GET PROPERTIES @@ -554,7 +615,7 @@ def auxiliary_lines_dash_length(self): @property def text_offset(self): - return Pt.from_object(self.get_value("textOffset")) + return Pt(*self.get_value("textOffset")) # SET PROPERTIES @@ -566,8 +627,7 @@ def set_auxiliary_lines_visible(self, visible): def set_auxiliary_lines_dash_length(self, length): self.call_action("setAuxiliaryLineDashLength", length) - @validate(Size(), Size()) - def set_text_offset(self, x, y): - [(x, y)] = self._from_angular_sizes([(x, y)]) + @validate(Number(), Number()) + def set_text_offset(self, x, y): # TODO pixel only! self.call_action("setTextOffset", x, True) self.call_action("setTextOffset", y, False) From 90c4e92a2a20c23bee1b8cb25b73892620cc28f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 28 Aug 2023 22:36:13 +0200 Subject: [PATCH 15/50] Added some docstrings and revised some parameters / validation --- carta/region.py | 303 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 265 insertions(+), 38 deletions(-) diff --git a/carta/region.py b/carta/region.py index fa83510..941008a 100644 --- a/carta/region.py +++ b/carta/region.py @@ -43,6 +43,23 @@ def list(self): @validate(Number()) def get(self, region_id): + """Return the region with the given region ID. + + Parameters + ---------- + region_id : {0} + The region ID. + + Returns + ------- + :obj:`carta.region.Region` object + The region with the given ID. + + Raises + ------ + ValueError + If there is no region with the given ID. + """ try: region_type = self.get_value(f"regionMap[{region_id}]", return_path="regionType") except CartaBadResponse: @@ -95,9 +112,9 @@ def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileTy @validate(Constant(RegionType), IterableOf(Point.NumericPoint()), Number(), String()) def add_region(self, region_type, points, rotation=0, name=""): - """Add a new region to this image. + """Add a new region or annotation to this image. - This is a generic low-level function. Also see the higher-level functions for adding regions of specific types, like :obj:`carta.image.add_region_rectangular`. + This is a generic low-level function. Also see the higher-level functions for adding regions of specific types, such as :obj:`carta.region.RegionSet.add_point`. Parameters ---------- @@ -106,13 +123,33 @@ def add_region(self, region_type, points, rotation=0, name=""): points : {1} The control points defining the region, in image coordinates. How these values are interpreted depends on the region type. rotation : {2} - The rotation of the region, in degrees. + The rotation of the region, in degrees. Defaults to zero. name : {3} The name of the region. Defaults to the empty string. """ return Region.new(self, region_type, points, rotation, name) def _from_world_coordinates(self, points): + """Internal utility function for coercing world or image coordinates to image coordinates. This is used in various region functions to simplify accepting both world and image coordinates. + + The points provided must either all be world coordinates or all be image coordinates. This can be enforced with the appropriate validation on the calling method. + + If the points provided are world coordinates, the method on the image object is called successfully and returns the points transformed into image coordinates, which this method returns. + + If the points provided are image coordinates, the type validation on the image method fails. This exception is caught silently by this method, and the unmodified points are returned. + + See also :obj:`carta.region.RegionSet._from_angular_sizes`. + + Parameters + ---------- + points : iterable of points which are all either world or image coordinates + The input points. + + Returns + ------- + iterable of points which are image coordinates + The output points. + """ try: points = self.image.from_world_coordinate_points(points) except CartaValidationFailed: @@ -120,6 +157,26 @@ def _from_world_coordinates(self, points): return points def _from_angular_sizes(self, points): + """Internal utility function for coercing angular or pixel sizes to pixel sizes. This is used in various region functions to simplify accepting both angular and pixel sizes. + + The points provided must either all be angular sizes or all be pixel sizes. This can be enforced with the appropriate validation on the calling method. + + If the points provided are angular sizes, the method on the image object is called successfully and returns the points transformed into pixel sizes. + + If the points provided are in pixel sizes, the type validation on the image method fails. This exception is caught silently by this method, and the unmodified points are returned. + + See also :obj:`carta.region.RegionSet._from_world_coordinates`. + + Parameters + ---------- + points : iterable of points which are all either angular or pixel sizes + The input points. + + Returns + ------- + iterable of points which are pixel sizes + The output points. + """ try: points = self.image.from_angular_size_points(points) except CartaValidationFailed: @@ -128,63 +185,239 @@ def _from_angular_sizes(self, points): @validate(Point.CoordinatePoint(), Boolean(), String()) def add_point(self, center, annotation=False, name=""): + """Add a new point region or point annotation to this image. + + Parameters + ---------- + center : {0} + The center position of the region. + annotation : {1} + Whether this region should be an annotation. Defaults to ``False``. + name : {2} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` or :obj:`carta.region.PointAnnotation` object + A new region object. + """ [center] = self._from_world_coordinates([center]) region_type = RegionType.ANNPOINT if annotation else RegionType.POINT return self.add_region(region_type, [center], name=name) - @validate(Point.CoordinatePoint(), Size(), Size(), Boolean(), Number(), String()) - def add_rectangle(self, center, width, height, annotation=False, rotation=0, name=""): + @validate(Point.CoordinatePoint(), Point.SizePoint(), Boolean(), Number(), String()) + def add_rectangle(self, center, size, annotation=False, rotation=0, name=""): + """Add a new rectangular region or rectangular annotation to this image. + + Parameters + ---------- + center : {0} + The center position of the region. + size : {1} + The size of the region. The ``x`` and ``y`` values will be interpreted as the width and height, respectively. + annotation : {2} + Whether this region should be an annotation. Defaults to ``False``. + rotation : {3} + The rotation of the region, in degrees. Defaults to zero. + name : {4} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` or :obj:`carta.region.Annotation` object + A new region object. + """ [center] = self._from_world_coordinates([center]) - [(width, height)] = self._from_angular_sizes([(width, height)]) + [size] = self._from_angular_sizes([size]) region_type = RegionType.ANNRECTANGLE if annotation else RegionType.RECTANGLE - return self.add_region(region_type, [center, (width, height)], rotation, name) + return self.add_region(region_type, [center, size], rotation, name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), Boolean(), Number(), String()) + def add_ellipse(self, center, size, annotation=False, rotation=0, name=""): + """Add a new elliptical region or elliptical annotation to this image. + + Parameters + ---------- + center : {0} + The center position of the region. + size : {1} + The size of the region. The ``x`` and ``y`` values will be interpreted as the semi-major and semi-minor axes, respectively. + annotation : {2} + Whether this region should be an annotation. Defaults to ``False``. + rotation : {3} + The rotation of the region, in degrees. Defaults to zero. + name : {4} + The name of the region. Defaults to the empty string. - @validate(Point.CoordinatePoint(), Size(), Size(), Boolean(), Number(), String()) - def add_ellipse(self, center, semi_major, semi_minor, annotation=False, rotation=0, name=""): + Returns + ------- + :obj:`carta.region.Region` or :obj:`carta.region.Annotation` object + A new region object. + """ [center] = self._from_world_coordinates([center]) - [(semi_major, semi_minor)] = self._from_angular_sizes([(semi_major, semi_minor)]) + [size] = self._from_angular_sizes([size]) region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE - return self.add_region(region_type, [center, (semi_major, semi_minor)], rotation, name) + return self.add_region(region_type, [center, size], rotation, name) @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) def add_polygon(self, points, annotation=False, name=""): + """Add a new polygonal region or polygonal annotation to this image. + + Parameters + ---------- + points : {0} + The positions of the vertices of the region, either all in world coordinates or all in image coordinates. + annotation : {1} + Whether this region should be an annotation. Defaults to ``False``. + name : {2} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.PolygonRegion` or :obj:`carta.region.PolygonAnnotation` object + A new region object. + """ points = self._from_world_coordinates(points) region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON return self.add_region(region_type, points, name=name) @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), Boolean(), String()) def add_line(self, start, end, annotation=False, name=""): + """Add a new line region or line annotation to this image. + + Parameters + ---------- + start : {0} + The start position of the region. + end : {1} + The end position of the region. + annotation : {2} + Whether this region should be an annotation. Defaults to ``False``. + name : {3} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.LineRegion` or :obj:`carta.region.LineAnnotation` object + A new region object. + """ [start, end] = self._from_world_coordinates([start, end]) region_type = RegionType.ANNLINE if annotation else RegionType.LINE return self.add_region(region_type, [start, end], name=name) @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) def add_polyline(self, points, annotation=False, name=""): + """Add a new polyline region or polyline annotation to this image. + + Parameters + ---------- + points : {0} + The positions of the vertices of the region, either all in world coordinates or all in image coordinates. + annotation : {1} + Whether this region should be an annotation. Defaults to ``False``. + name : {2} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.PolylineRegion` or :obj:`carta.region.PolylineAnnotation` object + A new region object. + """ points = self._from_world_coordinates(points) region_type = RegionType.ANNPOLYLINE if annotation else RegionType.POLYLINE return self.add_region(region_type, points, name=name) @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), String()) def add_vector(self, start, end, name=""): - [start, end] = self._from_world_coordinates([start, end]) + """Add a new vector annotation to this image. + + Parameters + ---------- + start : {0} + The start position of the region. + end : {1} + The end position of the region. + name : {2} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.VectorAnnotation` object + A new region object. + """ + [start] = self._from_world_coordinates([start]) # Parsed separately in case they are mismatched + [end] = self._from_world_coordinates([end]) # Parsed separately in case they are mismatched return self.add_region(RegionType.ANNVECTOR, [start, end], name=name) - @validate(Point.CoordinatePoint(), Size(), Size(), String(), Number(), String()) - def add_text(self, center, width, height, text, rotation=0, name=""): + @validate(Point.CoordinatePoint(), Point.SizePoint(), String(), Number(), String()) + def add_text(self, center, size, text, rotation=0, name=""): + """Add a new text annotation to this image. + + Parameters + ---------- + center : {0} + The center position of the region. + size : {1} + The size of the region. The ``x`` and ``y`` values will be interpreted as the width and height, respectively. + text : {2} + The text content to display. + rotation : {3} + The rotation of the region, in degrees. Defaults to zero. + name : {4} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.TextAnnotation` object + A new region object. + """ [center] = self._from_world_coordinates([center]) - [(width, height)] = self._from_angular_sizes([(width, height)]) - region = self.add_region(RegionType.ANNTEXT, [center, (width, height)], rotation, name) + [size] = self._from_angular_sizes([size]) + region = self.add_region(RegionType.ANNTEXT, [center, size], rotation, name) region.set_text(text) return region @validate(Point.CoordinatePoint(), Number(), String()) def add_compass(self, center, length, name=""): + """Add a new compass annotation to this image. + + Parameters + ---------- + center : {0} + The origin position of the compass. + length : {1} + The length of the compass points, in pixels. + name : {2} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.CompassAnnotation` object + A new region object. + """ [center] = self._from_world_coordinates([center]) return self.add_region(RegionType.ANNCOMPASS, [center, (length, length)], name=name) @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), String()) def add_ruler(self, start, end, name=""): - [start, end] = self._from_world_coordinates([start, end]) + """Add a new ruler annotation to this image. + + Parameters + ---------- + start : {0} + The start position of the region. + end : {1} + The end position of the region. + name : {2} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.RulerAnnotation` object + A new region object. + """ + [start] = self._from_world_coordinates([start]) # Parsed separately in case they are mismatched + [end] = self._from_world_coordinates([end]) # Parsed separately in case they are mismatched return self.add_region(RegionType.ANNRULER, [start, end], name=name) def clear(self): @@ -412,7 +645,6 @@ def set_endpoints(self, start, end): self.set_control_points([start, end]) -# TODO maybe consolidate these into single functions class HasFontMixin: # GET PROPERTIES @@ -431,20 +663,16 @@ def font(self): # SET PROPERTIES - @validate(Number()) - def set_font_size(self, size): - self.call_action("setFontSize", size) - - @validate(Constant(AnnotationFontStyle)) - def set_font_style(self, style): - self.call_action("setFontStyle", style) - - @validate(Constant(AnnotationFont)) - def set_font(self, font): - self.call_action("setFont", font) + @validate(*all_optional(Constant(AnnotationFont), Number(), Constant(AnnotationFontStyle))) + def set_font(self, font=None, size=None, style=None): + if font: + self.call_action("setFont", font) + if size is not None: + self.call_action("setFontSize", size) + if style: + self.call_action("setFontStyle", style) -# TODO maybe consolidate these into single functions class HasPointerMixin: # GET PROPERTIES @@ -459,13 +687,12 @@ def pointer_length(self): # SET PROPERTIES - @validate(Number()) - def set_pointer_width(self, width): - self.call_action("setPointerWidth", width) - - @validate(Number()) - def set_pointer_length(self, length): - self.call_action("setPointerLength", length) + @validate(*all_optional(Number(), Number())) + def set_pointer(self, width=None, length=None): + if width is not None: + self.call_action("setPointerWidth", width) + if length is not None: + self.call_action("setPointerLength", length) class Annotation(Region): @@ -583,7 +810,7 @@ def set_label(self, north_label=None, east_label=None): def set_length(self, length): self.call_action("setLength", length) - @validate(*all_optional(Point.NumericPoint(), Point.NumericPoint())) # TODO pixel only! + @validate(*all_optional(Point.NumericPoint(), Point.NumericPoint())) def set_text_offset(self, north_offset=None, east_offset=None): if north_offset is not None: self.call_action("setNorthTextOffset", north_offset.x, True) From b4481fd0f9eb64177de108e62f3229c0bdd9f4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Wed, 30 Aug 2023 00:24:06 +0200 Subject: [PATCH 16/50] Added most of the missing docstrings. --- carta/region.py | 457 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 411 insertions(+), 46 deletions(-) diff --git a/carta/region.py b/carta/region.py index 941008a..b0c28ba 100644 --- a/carta/region.py +++ b/carta/region.py @@ -7,7 +7,7 @@ from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse, CartaValidationFailed from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont -from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Size, Union +from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Union class RegionSet(BasePathMixin): @@ -473,24 +473,94 @@ def __repr__(self): @classmethod @validate(Constant(RegionType)) def region_class(cls, region_type): + """The region class associated for this type. + + Not every type maps to a specific class; some types have no specific functionality and use the default :obj:`carta.region.Region` or :obj:`carta.region.Annotation` classes. + + Parameters + ---------- + region_type : {0} + The region type. + + Returns + ------- + class object + The region class. + """ region_type = RegionType(region_type) return cls.CUSTOM_CLASS.get(region_type, Annotation if region_type.is_annotation else Region) @classmethod @validate(Constant(RegionType), InstanceOf(RegionSet), Number()) def existing(cls, region_type, region_set, region_id): + """Create a region object corresponding to an existing region. + + This is an internal helper method which should not be used directly. + + Parameters + ---------- + region_type : {0} + The region type. + region_set : {1} + The region set containing this region. + region_id : {2} + The ID of the region. + + Returns + ------- + :obj:`carta.region.Region` object + The region object. + """ return cls.region_class(region_type)(region_set, region_id) @classmethod - @validate(InstanceOf(RegionSet), Constant(RegionType), IterableOf(Point()), Number(), String()) + @validate(InstanceOf(RegionSet), Constant(RegionType), IterableOf(Point.NumericPoint()), Number(), String()) def new(cls, region_set, region_type, points, rotation=0, name=""): - points = [Pt(*point) for point in points] # TODO at this point we should already have pixel points here + """Create a new region. + + This is an internal helper method which should not be used directly. + + Parameters + ---------- + region_set : {0} + The region set in which to create this region. + region_type : {1} + The region type. + points : {2} + The control points of the region, in pixels. These may be coordinates or sizes; how they are interpreted depends on the region type. + rotation : {3} + The rotation of the region, in degrees. Defaults to zero. + name : {4} + The name of the region. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` object + The region object. + """ + points = [Pt(*point) for point in points] region_id = region_set.call_action("addRegionAsync", region_type, points, rotation, name, return_path="regionId") return cls.existing(region_type, region_set, region_id) @classmethod @validate(InstanceOf(RegionSet), IterableOf(MapOf(String(), Number(), required_keys={"type", "id"}))) def from_list(cls, region_set, region_list): + """Create region objects corresponding to a list of existing regions in a single region set. + + This is an internal helper method which should not be used directly. + + Parameters + ---------- + region_set : {0} + The region set which contains these regions. + region_list : {1} + A list of dictionaries containing region types and IDs. + + Returns + ------- + iterable of :obj:`carta.region.Region` objects + The region objects. + """ return [cls.existing(r["type"], region_set, r["id"]) for r in region_list] # GET PROPERTIES @@ -498,110 +568,254 @@ def from_list(cls, region_set, region_list): @property @cached def region_type(self): + """The type of the region. + + Returns + ------- + :obj:`carta.constants.RegionType` object + The type. + """ return RegionType(self.get_value("regionType")) @property def center(self): + """The center position of the region, in image coordinates. + + Returns + ------- + tuple of two numbers + The center position. + """ return Pt(**self.get_value("center")).as_tuple() @property def wcs_center(self): + """The center position of the region, in world coordinates. + + Returns + ------- + tuple of two strings + The center position. + """ [center] = self.region_set.image.to_world_coordinate_points([self.center]) return center @property def size(self): + """The size of the region, in pixels. + + Returns + ------- + tuple of two numbers + The size. The first value is the width, and the second value is the height. + """ return Pt(**self.get_value("size")).as_tuple() @property def wcs_size(self): + """The size of the region, in angular size units. + + Returns + ------- + tuple of two strings + The size. The first value is the width, and the second value is the height. + """ size = self.get_value("wcsSize") return (f"{size['x']}\"", f"{size['y']}\"") @property def rotation(self): + """The rotation of the region, in degrees. + + Returns + ------- + number + The rotation. + """ return self.get_value("rotation") @property def control_points(self): + """The control points of the region, in pixels. + + Returns + ------- + iterable of tuples of two numbers + The control points. + """ return [Pt(**p).as_tuple() for p in self.get_value("controlPoints")] @property def name(self): + """The name of the region. + + Returns + ------- + string + The name. + """ return self.get_value("name") @property def color(self): + """The color of the region. + + Returns + ------- + string + The color. + """ return self.get_value("color") @property def line_width(self): + """The line width of the region, in pixels. + + Returns + ------- + number + The line width. + """ return self.get_value("lineWidth") @property def dash_length(self): + """The dash length of the region, in pixels. + + Returns + ------- + number + The dash length. + """ return self.get_value("dashLength") # SET PROPERTIES @validate(Point.CoordinatePoint()) def set_center(self, center): + """Set the center position of this region. + + Both image and world coordinates are accepted, but both values must match. + + Parameters + ---------- + center : {0} + The new center position. + """ [center] = self.region_set._from_world_coordinates([center]) self.call_action("setCenter", Pt(*center)) - @validate(Size(), Size()) - def set_size(self, x, y): - [(x, y)] = self.region_set._from_angular_sizes([(x, y)]) - self.call_action("setSize", Pt(x, y)) + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size of this region. + + TODO list region types for which this does not work. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new size. + """ + [size] = self.region_set._from_angular_sizes([size]) + self.call_action("setSize", Pt(*size)) @validate(Number(), Point.NumericPoint()) def set_control_point(self, index, point): + """Update the value of a single control point of this region. + + Parameters + ---------- + index : {0} + The index of the control point to update. + point : {1} + The new value for the control point, in pixels. + """ self.call_action("setControlPoint", index, Pt(*point)) @validate(IterableOf(Point.NumericPoint())) def set_control_points(self, points): + """Update all the control points of this region. + + Parameters + ---------- + points : {0} + The new control points, in pixels. + """ self.call_action("setControlPoints", [Pt(*p) for p in points]) @validate(Number()) def set_rotation(self, angle): - """Set the rotation of this region to the given angle. + """Set the rotation of this region. Parameters ---------- angle : {0} - The new rotation angle. + The new rotation angle, in degrees. """ self.call_action("setRotation", angle) @validate(String()) def set_name(self, name): + """Set the name of this region. + + Parameters + ---------- + name : {0} + The new name. + """ self.call_action("setName", name) - @validate(Color()) - def set_color(self, color): - self.call_action("setColor", color) + @validate(*all_optional(Color(), Number(), Number())) + def set_line_style(self, color=None, line_width=None, dash_length=None): + """Set the line style of this region. - @validate(Number()) - def set_line_width(self, width): - self.call_action("setLineWidth", width) + All parameters are optional. Omitted properties will be left unmodified. - @validate(Number()) - def set_dash_length(self, length): - self.call_action("setDashLength", length) + Parameters + ---------- + color : {0} + The new color. + line_width : {1} + The new line width, in pixels. + dash_length : {2} + The new dash length, in pixels. + """ + if color is not None: + self.call_action("setColor", color) + if line_width is not None: + self.call_action("setLineWidth", line_width) + if dash_length is not None: + self.call_action("setDashLength", dash_length) def lock(self): + """Lock this region.""" self.call_action("setLocked", True) def unlock(self): + """Unlock this region.""" self.call_action("setLocked", False) def focus(self): + """Center the image view on this region.""" self.call_action("focusCenter") # IMPORT AND EXPORT @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG)) def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF): + """Export this region into a file. + + Parameters + ---------- + path : {0} + The path where the file should be saved, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + coordinate_type : {1} + The coordinate type to use (world coordinates by default). + file_type : {2} + The region file type to use (CRTF by default). + """ self.region_set.export_to(path, coordinate_type, file_type, [self.region_id]) def delete(self): @@ -609,90 +823,230 @@ def delete(self): self.region_set.call_action("deleteRegion", self._region) -class HasPositionsMixin: +class HasVerticesMixin: + """This is a mixin class for regions which are defined by an arbitrary number of vertices. It assumes that all control points of the region should be interpreted as coordinates.""" + + # GET PROPERTIES + @property - def positions(self): + def vertices(self): + """The vertices of the region, in image coordinates. + + This is an alias of :obj:`carta.region.Region.control_points`. + + Returns + ------- + iterable of tuples of two numbers + The vertices. + """ return self.control_points @property - def wcs_positions(self): + def wcs_vertices(self): + """The vertices of the region, in world coordinates. + + Returns + ------- + iterable of tuples of two strings + The vertices. + """ return self.region_set.image.to_world_coordinate_points[self.control_points] - @validate(IterableOf(Point.CoordinatePoint())) - def set_positions(self, points): - points = self.region_set._from_world_coordinates(points) - self.set_control_points(points) + # SET PROPERTIES - @validate(Point.CoordinatePoint()) - def set_position(self, index, point): + @validate(Number(), Point.CoordinatePoint()) + def set_vertex(self, index, point): + """Update the value of a single vertex of this region. + + Parameters + ---------- + index : {0} + The index of the vertex to update. + point : {1} + The new value for the vertex, in image or world coordinates. + """ [point] = self.region_set._from_world_coordinates([point]) self.set_control_point(index, point) + @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint()))) + def set_vertices(self, points): + """Update all the vertices of this region. + + Both image and world coordinates are accepted, but all values must match. + + Parameters + ---------- + points : {0} + The new vertices, in image or world coordinates. + """ + points = self.region_set._from_world_coordinates(points) + self.set_control_points(points) + class HasEndpointsMixin: + """This is a mixin class for regions which are defined by two endpoints. It assumes that the region has two control points and both should be interpreted as coordinates.""" + + # GET PROPERTIES + @property def endpoints(self): + """The endpoints of the region, in image coordinates. + + This is an alias of :obj:`carta.region.Region.control_points`. + + Returns + ------- + iterable of tuples of two numbers + The endpoints. + """ return self.control_points @property def wcs_endpoints(self): + """The endpoints of the region, in world coordinates. + + Returns + ------- + iterable of tuples of two strings + The endpoints. + """ return self.region_set.image.to_world_coordinate_points[self.control_points] - @validate(Point.CoordinatePoint(), Point.CoordinatePoint()) - def set_endpoints(self, start, end): - [start] = self.region_set._from_world_coordinates([start]) - [end] = self.region_set._from_world_coordinates([end]) - self.set_control_points([start, end]) + # SET PROPERTIES + + @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) + def set_endpoints(self, start=None, end=None): + """Update the endpoints of this region. + + Both parameters are optional. If an endpoint is omitted, it will not be modified. + + Both image and world coordinates are accepted, but both values in each point must match. + + Parameters + ---------- + start : {0} + The new start position, in image or world coordinates. + end : {1} + The new end position, in image or world coordinates. + """ + if start is not None: + [start] = self.region_set._from_world_coordinates([start]) + self.set_control_point(0, start) + if end is not None: + [end] = self.region_set._from_world_coordinates([end]) + self.set_control_point(1, end) class HasFontMixin: + """This is a mixin class for annotations which have font properties.""" # GET PROPERTIES @property def font_size(self): + """The font size of this annotation, in pixels. + + Returns + ------- + number + The font size. + """ return self.get_value("fontSize") @property def font_style(self): + """The font style of this annotation. + + Returns + ------- + :obj:`carta.constants.AnnotationFontStyle` + The font style. + """ return AnnotationFontStyle(self.get_value("fontStyle")) @property def font(self): + """The font of this annotation. + + Returns + ------- + :obj:`carta.constants.AnnotationFont` + The font. + """ return AnnotationFont(self.get_value("font")) # SET PROPERTIES @validate(*all_optional(Constant(AnnotationFont), Number(), Constant(AnnotationFontStyle))) - def set_font(self, font=None, size=None, style=None): + def set_font(self, font=None, font_size=None, font_style=None): + """Set the font properties of this annotation. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + font : {0} + The font face. + font_size : {1} + The font size, in pixels. + font_style : {2} + The font style. + """ if font: self.call_action("setFont", font) - if size is not None: - self.call_action("setFontSize", size) - if style: - self.call_action("setFontStyle", style) + if font_size is not None: + self.call_action("setFontSize", font_size) + if font_style: + self.call_action("setFontStyle", font_style) class HasPointerMixin: + """This is a mixin class for annotations which have a pointer style.""" # GET PROPERTIES @property def pointer_width(self): + """The pointer width of this annotation, in pixels. + + Returns + ------- + number + The pointer width. + """ return self.get_value("pointerWidth") @property def pointer_length(self): + """The pointer length of this annotation, in pixels. + + Returns + ------- + number + The pointer length. + """ return self.get_value("pointerLength") # SET PROPERTIES @validate(*all_optional(Number(), Number())) - def set_pointer(self, width=None, length=None): - if width is not None: - self.call_action("setPointerWidth", width) - if length is not None: - self.call_action("setPointerLength", length) + def set_pointer_style(self, pointer_width=None, pointer_length=None): + """Set the pointer style of this annotation. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + pointer_width : {0} + The pointer width, in pixels. + pointer_length : {1} + The pointer length, in pixels. + """ + + if pointer_width is not None: + self.call_action("setPointerWidth", pointer_width) + if pointer_length is not None: + self.call_action("setPointerLength", pointer_length) class Annotation(Region): @@ -701,30 +1055,37 @@ class Annotation(Region): class LineRegion(Region, HasEndpointsMixin): + """A line region.""" REGION_TYPE = RegionType.LINE -class PolylineRegion(Region, HasPositionsMixin): +class PolylineRegion(Region, HasVerticesMixin): + """A polyline region.""" REGION_TYPE = RegionType.POLYLINE -class PolygonRegion(Region, HasPositionsMixin): +class PolygonRegion(Region, HasVerticesMixin): + """A polygonal region.""" REGION_TYPE = RegionType.POLYGON class LineAnnotation(Annotation, HasEndpointsMixin): + """A line annotation.""" REGION_TYPE = RegionType.ANNLINE -class PolylineAnnotation(Annotation, HasPositionsMixin): +class PolylineAnnotation(Annotation, HasVerticesMixin): + """A polyline annotation.""" REGION_TYPE = RegionType.ANNPOLYLINE -class PolygonAnnotation(Annotation, HasPositionsMixin): +class PolygonAnnotation(Annotation, HasVerticesMixin): + """A polygonal annotation.""" REGION_TYPE = RegionType.ANNPOLYGON class PointAnnotation(Annotation): + """A point annotation.""" REGION_TYPE = RegionType.ANNPOINT # GET PROPERTIES @@ -749,6 +1110,7 @@ def set_point_width(self, width): class TextAnnotation(Annotation, HasFontMixin): + """A text annotation.""" REGION_TYPE = RegionType.ANNTEXT # GET PROPERTIES @@ -773,10 +1135,12 @@ def set_position(self, position): class VectorAnnotation(Annotation, HasPointerMixin, HasEndpointsMixin): + """A vector annotation.""" REGION_TYPE = RegionType.ANNVECTOR class CompassAnnotation(Annotation, HasFontMixin, HasPointerMixin): + """A compass annotation.""" REGION_TYPE = RegionType.ANNCOMPASS # GET PROPERTIES @@ -828,6 +1192,7 @@ def set_arrowhead_visible(self, north=None, east=None): class RulerAnnotation(Annotation, HasFontMixin, HasEndpointsMixin): + """A ruler annotation.""" REGION_TYPE = RegionType.ANNRULER # GET PROPERTIES From 39002cf37dcc8542d8d288ccd32f3d1dd7f2211a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 1 Sep 2023 01:02:00 +0200 Subject: [PATCH 17/50] Added missing docstrings. Made various API changes to the region classes. Refactored region class hierarchy. --- carta/image.py | 27 +- carta/region.py | 711 +++++++++++++++++++++++++++++++++++++----------- carta/util.py | 42 ++- 3 files changed, 610 insertions(+), 170 deletions(-) diff --git a/carta/image.py b/carta/image.py index d8469be..0feb00e 100644 --- a/carta/image.py +++ b/carta/image.py @@ -681,7 +681,7 @@ def from_world_coordinate_points(self, points): Parameters ---------- - points : {} + points : {0} Points with string values which are valid world coordinates. Returns @@ -701,7 +701,7 @@ def to_world_coordinate_points(self, points): Parameters ---------- - points : {} + points : {0} Points with numeric values which are valid image coordinates. Returns @@ -743,7 +743,7 @@ def from_angular_size_points(self, points): Parameters ---------- - points : {} + points : {0} Points with string values which are valid angular sizes. Returns @@ -756,6 +756,27 @@ def from_angular_size_points(self, points): converted_points.append((self.from_angular_size(x, SpatialAxis.X), self.from_angular_size(y, SpatialAxis.Y))) return converted_points + @validate(IterableOf(Point.NumericPoint())) + def to_angular_size_points(self, points): + """Convert pixel size points to angular size points. + + The points must be numeric. + + Parameters + ---------- + points : {0} + Points with numeric values which are valid image coordinates. + + Returns + ------- + iterable of angular size points + Points with string values which are angular sizes. + """ + converted_points = [] + for p in points: + converted_points.append(self.call_action("getWcsSizeInArcsec", Pt(*p))) + return converted_points + # CLOSE def close(self): diff --git a/carta/region.py b/carta/region.py index b0c28ba..d0373b9 100644 --- a/carta/region.py +++ b/carta/region.py @@ -6,8 +6,8 @@ import posixpath from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse, CartaValidationFailed -from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont -from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Union +from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont, SpatialAxis +from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Union, Size class RegionSet(BasePathMixin): @@ -190,11 +190,11 @@ def add_point(self, center, annotation=False, name=""): Parameters ---------- center : {0} - The center position of the region. + The center position. annotation : {1} - Whether this region should be an annotation. Defaults to ``False``. + Whether the region should be an annotation. Defaults to ``False``. name : {2} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -212,19 +212,19 @@ def add_rectangle(self, center, size, annotation=False, rotation=0, name=""): Parameters ---------- center : {0} - The center position of the region. + The center position. size : {1} - The size of the region. The ``x`` and ``y`` values will be interpreted as the width and height, respectively. + The size. The two values will be interpreted as the width and height, respectively. annotation : {2} Whether this region should be an annotation. Defaults to ``False``. rotation : {3} - The rotation of the region, in degrees. Defaults to zero. + The rotation, in degrees. Defaults to zero. name : {4} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- - :obj:`carta.region.Region` or :obj:`carta.region.Annotation` object + :obj:`carta.region.Region` object A new region object. """ [center] = self._from_world_coordinates([center]) @@ -233,31 +233,32 @@ def add_rectangle(self, center, size, annotation=False, rotation=0, name=""): return self.add_region(region_type, [center, size], rotation, name) @validate(Point.CoordinatePoint(), Point.SizePoint(), Boolean(), Number(), String()) - def add_ellipse(self, center, size, annotation=False, rotation=0, name=""): + def add_ellipse(self, center, semi_axes, annotation=False, rotation=0, name=""): """Add a new elliptical region or elliptical annotation to this image. Parameters ---------- center : {0} - The center position of the region. - size : {1} - The size of the region. The ``x`` and ``y`` values will be interpreted as the semi-major and semi-minor axes, respectively. + The center position. + semi_axes : {1} + The semi-axes. The two values will be interpreted as the north-south and east-west axes, respectively. annotation : {2} Whether this region should be an annotation. Defaults to ``False``. rotation : {3} - The rotation of the region, in degrees. Defaults to zero. + The rotation, in degrees. Defaults to zero. name : {4} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- - :obj:`carta.region.Region` or :obj:`carta.region.Annotation` object + :obj:`carta.region.Region` object A new region object. """ [center] = self._from_world_coordinates([center]) - [size] = self._from_angular_sizes([size]) + [semi_axes] = self._from_angular_sizes([semi_axes]) + region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE - return self.add_region(region_type, [center, size], rotation, name) + return self.add_region(region_type, [center, semi_axes], rotation, name) @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) def add_polygon(self, points, annotation=False, name=""): @@ -266,11 +267,11 @@ def add_polygon(self, points, annotation=False, name=""): Parameters ---------- points : {0} - The positions of the vertices of the region, either all in world coordinates or all in image coordinates. + The positions of the vertices, either all in world coordinates or all in image coordinates. annotation : {1} Whether this region should be an annotation. Defaults to ``False``. name : {2} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -288,13 +289,13 @@ def add_line(self, start, end, annotation=False, name=""): Parameters ---------- start : {0} - The start position of the region. + The start position. end : {1} - The end position of the region. + The end position. annotation : {2} Whether this region should be an annotation. Defaults to ``False``. name : {3} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -312,11 +313,11 @@ def add_polyline(self, points, annotation=False, name=""): Parameters ---------- points : {0} - The positions of the vertices of the region, either all in world coordinates or all in image coordinates. + The positions of the vertices, either all in world coordinates or all in image coordinates. annotation : {1} Whether this region should be an annotation. Defaults to ``False``. name : {2} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -334,11 +335,11 @@ def add_vector(self, start, end, name=""): Parameters ---------- start : {0} - The start position of the region. + The start position. end : {1} - The end position of the region. + The end position. name : {2} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -356,15 +357,15 @@ def add_text(self, center, size, text, rotation=0, name=""): Parameters ---------- center : {0} - The center position of the region. + The center position. size : {1} - The size of the region. The ``x`` and ``y`` values will be interpreted as the width and height, respectively. + The size. The two values will be interpreted as the width and height, respectively. text : {2} The text content to display. rotation : {3} - The rotation of the region, in degrees. Defaults to zero. + The rotation, in degrees. Defaults to zero. name : {4} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -388,7 +389,7 @@ def add_compass(self, center, length, name=""): length : {1} The length of the compass points, in pixels. name : {2} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -405,11 +406,11 @@ def add_ruler(self, start, end, name=""): Parameters ---------- start : {0} - The start position of the region. + The start position. end : {1} - The end position of the region. + The end position. name : {2} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -456,6 +457,9 @@ def __init_subclass__(cls, **kwargs): if cls.REGION_TYPE is not None: Region.CUSTOM_CLASS[cls.REGION_TYPE] = cls + elif cls.REGION_TYPES is not None: + for t in cls.REGION_TYPES: + Region.CUSTOM_CLASS[t] = cls def __init__(self, region_set, region_id): self.region_set = region_set @@ -475,7 +479,7 @@ def __repr__(self): def region_class(cls, region_type): """The region class associated for this type. - Not every type maps to a specific class; some types have no specific functionality and use the default :obj:`carta.region.Region` or :obj:`carta.region.Annotation` classes. + Not every type maps to a specific class; some types have no specific functionality and use the default :obj:`carta.region.Region` class. Parameters ---------- @@ -488,7 +492,7 @@ class object The region class. """ region_type = RegionType(region_type) - return cls.CUSTOM_CLASS.get(region_type, Annotation if region_type.is_annotation else Region) + return cls.CUSTOM_CLASS.get(region_type, Region) @classmethod @validate(Constant(RegionType), InstanceOf(RegionSet), Number()) @@ -502,7 +506,7 @@ def existing(cls, region_type, region_set, region_id): region_type : {0} The region type. region_set : {1} - The region set containing this region. + The region set containing the region. region_id : {2} The ID of the region. @@ -527,11 +531,11 @@ def new(cls, region_set, region_type, points, rotation=0, name=""): region_type : {1} The region type. points : {2} - The control points of the region, in pixels. These may be coordinates or sizes; how they are interpreted depends on the region type. + The control points, in pixels. These may be coordinates or sizes; how they are interpreted depends on the region type. rotation : {3} - The rotation of the region, in degrees. Defaults to zero. + The rotation, in degrees. Defaults to zero. name : {4} - The name of the region. Defaults to the empty string. + The name. Defaults to the empty string. Returns ------- @@ -568,7 +572,7 @@ def from_list(cls, region_set, region_list): @property @cached def region_type(self): - """The type of the region. + """The region type. Returns ------- @@ -579,75 +583,77 @@ def region_type(self): @property def center(self): - """The center position of the region, in image coordinates. + """The center position, in image coordinates. Returns ------- - tuple of two numbers - The center position. + number + The X coordinate of the center position. + number + The Y coordinate of the center position. """ return Pt(**self.get_value("center")).as_tuple() @property def wcs_center(self): - """The center position of the region, in world coordinates. + """The center position, in world coordinates. Returns ------- - tuple of two strings - The center position. + string + The X coordinate of the center position. + string + The Y coordinate of the center position. """ [center] = self.region_set.image.to_world_coordinate_points([self.center]) return center @property def size(self): - """The size of the region, in pixels. + """The size, in pixels. Returns ------- - tuple of two numbers - The size. The first value is the width, and the second value is the height. + number + The width. + number + The height. """ - return Pt(**self.get_value("size")).as_tuple() + size = self.get_value("size") + if not size: + return None + return Pt(**size).as_tuple() @property def wcs_size(self): - """The size of the region, in angular size units. + """The size, in angular size units. Returns ------- - tuple of two strings - The size. The first value is the width, and the second value is the height. + string + The width. + string + The height. """ size = self.get_value("wcsSize") + if size['x'] is None or size['y'] is None: + return None return (f"{size['x']}\"", f"{size['y']}\"") - @property - def rotation(self): - """The rotation of the region, in degrees. - - Returns - ------- - number - The rotation. - """ - return self.get_value("rotation") - @property def control_points(self): - """The control points of the region, in pixels. + """The control points. Returns ------- iterable of tuples of two numbers - The control points. + The control points, in pixels. """ return [Pt(**p).as_tuple() for p in self.get_value("controlPoints")] @property def name(self): - """The name of the region. + """The name. Returns ------- @@ -658,7 +664,7 @@ def name(self): @property def color(self): - """The color of the region. + """The color. Returns ------- @@ -669,23 +675,23 @@ def color(self): @property def line_width(self): - """The line width of the region, in pixels. + """The line width. Returns ------- number - The line width. + The line width, in pixels. """ return self.get_value("lineWidth") @property def dash_length(self): - """The dash length of the region, in pixels. + """The dash length. Returns ------- number - The dash length. + The dash length, in pixels. """ return self.get_value("dashLength") @@ -693,7 +699,7 @@ def dash_length(self): @validate(Point.CoordinatePoint()) def set_center(self, center): - """Set the center position of this region. + """Set the center position. Both image and world coordinates are accepted, but both values must match. @@ -707,9 +713,7 @@ def set_center(self, center): @validate(Point.SizePoint()) def set_size(self, size): - """Set the size of this region. - - TODO list region types for which this does not work. + """Set the size. Both pixel and angular sizes are accepted, but both values must match. @@ -723,7 +727,7 @@ def set_size(self, size): @validate(Number(), Point.NumericPoint()) def set_control_point(self, index, point): - """Update the value of a single control point of this region. + """Update the value of a single control point. Parameters ---------- @@ -736,7 +740,7 @@ def set_control_point(self, index, point): @validate(IterableOf(Point.NumericPoint())) def set_control_points(self, points): - """Update all the control points of this region. + """Update all the control points. Parameters ---------- @@ -745,20 +749,9 @@ def set_control_points(self, points): """ self.call_action("setControlPoints", [Pt(*p) for p in points]) - @validate(Number()) - def set_rotation(self, angle): - """Set the rotation of this region. - - Parameters - ---------- - angle : {0} - The new rotation angle, in degrees. - """ - self.call_action("setRotation", angle) - @validate(String()) def set_name(self, name): - """Set the name of this region. + """Set the name. Parameters ---------- @@ -769,7 +762,7 @@ def set_name(self, name): @validate(*all_optional(Color(), Number(), Number())) def set_line_style(self, color=None, line_width=None, dash_length=None): - """Set the line style of this region. + """Set the line style. All parameters are optional. Omitted properties will be left unmodified. @@ -822,6 +815,38 @@ def delete(self): """Delete this region.""" self.region_set.call_action("deleteRegion", self._region) +# TODO also factor out size, and exclude it from the point region? + + +class HasRotationMixin: + """This is a mixin class for regions which can be rotated.""" + + # GET PROPERTIES + + @property + def rotation(self): + """The rotation, in degrees. + + Returns + ------- + number + The rotation. + """ + return self.get_value("rotation") + + # SET PROPERTIES + + @validate(Number()) + def set_rotation(self, angle): + """Set the rotation. + + Parameters + ---------- + angle : {0} + The new rotation, in degrees. + """ + self.call_action("setRotation", angle) + class HasVerticesMixin: """This is a mixin class for regions which are defined by an arbitrary number of vertices. It assumes that all control points of the region should be interpreted as coordinates.""" @@ -830,7 +855,7 @@ class HasVerticesMixin: @property def vertices(self): - """The vertices of the region, in image coordinates. + """The vertices, in image coordinates. This is an alias of :obj:`carta.region.Region.control_points`. @@ -843,20 +868,20 @@ def vertices(self): @property def wcs_vertices(self): - """The vertices of the region, in world coordinates. + """The vertices, in world coordinates. Returns ------- iterable of tuples of two strings The vertices. """ - return self.region_set.image.to_world_coordinate_points[self.control_points] + return self.region_set.image.to_world_coordinate_points(self.control_points) # SET PROPERTIES @validate(Number(), Point.CoordinatePoint()) def set_vertex(self, index, point): - """Update the value of a single vertex of this region. + """Update the value of a single vertex. Parameters ---------- @@ -870,7 +895,7 @@ def set_vertex(self, index, point): @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint()))) def set_vertices(self, points): - """Update all the vertices of this region. + """Update all the vertices. Both image and world coordinates are accepted, but all values must match. @@ -890,33 +915,33 @@ class HasEndpointsMixin: @property def endpoints(self): - """The endpoints of the region, in image coordinates. + """The endpoints, in image coordinates. This is an alias of :obj:`carta.region.Region.control_points`. Returns ------- - iterable of tuples of two numbers + iterable containing two tuples of two numbers The endpoints. """ return self.control_points @property def wcs_endpoints(self): - """The endpoints of the region, in world coordinates. + """The endpoints, in world coordinates. Returns ------- - iterable of tuples of two strings + iterable containing two tuples of two strings The endpoints. """ - return self.region_set.image.to_world_coordinate_points[self.control_points] + return self.region_set.image.to_world_coordinate_points(self.control_points) # SET PROPERTIES @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) def set_endpoints(self, start=None, end=None): - """Update the endpoints of this region. + """Update the endpoints. Both parameters are optional. If an endpoint is omitted, it will not be modified. @@ -944,18 +969,18 @@ class HasFontMixin: @property def font_size(self): - """The font size of this annotation, in pixels. + """The font size. Returns ------- number - The font size. + The font size, in pixels. """ return self.get_value("fontSize") @property def font_style(self): - """The font style of this annotation. + """The font style. Returns ------- @@ -966,7 +991,7 @@ def font_style(self): @property def font(self): - """The font of this annotation. + """The font. Returns ------- @@ -979,7 +1004,7 @@ def font(self): @validate(*all_optional(Constant(AnnotationFont), Number(), Constant(AnnotationFontStyle))) def set_font(self, font=None, font_size=None, font_style=None): - """Set the font properties of this annotation. + """Set the font properties. All parameters are optional. Omitted properties will be left unmodified. @@ -1007,23 +1032,23 @@ class HasPointerMixin: @property def pointer_width(self): - """The pointer width of this annotation, in pixels. + """The pointer width. Returns ------- number - The pointer width. + The pointer width, in pixels. """ return self.get_value("pointerWidth") @property def pointer_length(self): - """The pointer length of this annotation, in pixels. + """The pointer length. Returns ------- number - The pointer length. + The pointer length, in pixels. """ return self.get_value("pointerLength") @@ -1031,7 +1056,7 @@ def pointer_length(self): @validate(*all_optional(Number(), Number())) def set_pointer_style(self, pointer_width=None, pointer_length=None): - """Set the pointer style of this annotation. + """Set the pointer style. All parameters are optional. Omitted properties will be left unmodified. @@ -1049,42 +1074,196 @@ def set_pointer_style(self, pointer_width=None, pointer_length=None): self.call_action("setPointerLength", pointer_length) -class Annotation(Region): - """Base class for annotations.""" - pass - - -class LineRegion(Region, HasEndpointsMixin): - """A line region.""" - REGION_TYPE = RegionType.LINE +class LineRegion(Region, HasEndpointsMixin, HasRotationMixin): + """A line region or annotation.""" + REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) class PolylineRegion(Region, HasVerticesMixin): - """A polyline region.""" - REGION_TYPE = RegionType.POLYLINE + """A polyline region or annotation.""" + REGION_TYPES = (RegionType.POLYLINE, RegionType.ANNPOLYLINE) class PolygonRegion(Region, HasVerticesMixin): - """A polygonal region.""" - REGION_TYPE = RegionType.POLYGON + """A polygonal region or annotation.""" + REGION_TYPES = (RegionType.POLYGON, RegionType.ANNPOLYGON) + + +class RectangularRegion(Region, HasRotationMixin): + """A rectangular region or annotation.""" + REGION_TYPES = (RegionType.RECTANGLE, RegionType.ANNRECTANGLE) + + # GET PROPERTIES + @property + def corners(self): + """The corner positions, in image coordinates. + + Returns + ------- + iterable containing two tuples of two numbers + The bottom-left and top-right corner positions, in image coordinates. + """ + center = Pt(*self.center) + size = Pt(*self.size) + dx, dy = size.x / 2, size.y / 2 + return ((center.x - dx, center.y - dy), (center.x + dx, center.y + dy)) -class LineAnnotation(Annotation, HasEndpointsMixin): - """A line annotation.""" - REGION_TYPE = RegionType.ANNLINE + @property + def wcs_corners(self): + """The corner positions, in world coordinates. + Returns + ------- + iterable containing two tuples of two strings + The bottom-left and top-right corner positions, in world coordinates. + """ + return self.region_set.image.to_world_coordinate_points(self.corners) -class PolylineAnnotation(Annotation, HasVerticesMixin): - """A polyline annotation.""" - REGION_TYPE = RegionType.ANNPOLYLINE + # SET PROPERTIES + @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) + def set_corners(self, bottom_left=None, top_right=None): + """Update the corner positions. + + Both parameters are optional. If a position is omitted, it will not be modified. + + The corner positions will be used to calculate the updated center position and size. + + Both image and world coordinates are accepted, but both values in each point must match. + + Parameters + ---------- + bottom_left : {0} + The new bottom-left corner position, in image or world coordinates. + top_right : {1} + The new top-right corner position, in image or world coordinates. + """ + if bottom_left is None or top_right is None: + current_bottom_left, current_top_right = self.corners -class PolygonAnnotation(Annotation, HasVerticesMixin): - """A polygonal annotation.""" - REGION_TYPE = RegionType.ANNPOLYGON + if bottom_left is not None: + [bottom_left] = self.region_set._from_world_coordinates([bottom_left]) + else: + bottom_left = current_bottom_left + if top_right is not None: + [top_right] = self.region_set._from_world_coordinates([top_right]) + else: + top_right = current_top_right -class PointAnnotation(Annotation): + bl = Pt(*bottom_left) + tr = Pt(*top_right) + + size = Pt(tr.x - bl.x, tr.y - bl.y) + center = (bl.x + (size.x / 2), bl.y + (size.y / 2)) + + self.set_control_points(center, size.as_tuple()) + + +class EllipticalRegion(Region, HasRotationMixin): + """An elliptical region or annotation.""" + REGION_TYPES = (RegionType.ELLIPSE, RegionType.ANNELLIPSE) + + # GET PROPERTIES + + @property + def semi_axes(self): + """The semi-axes, in pixels. + + The north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Returns + ------- + number + The north-south semi-axis, in pixels. + number + The east-west semi-axis, in pixels. + """ + return super().size + + @property + def wcs_semi_axes(self): + """The semi-axes, in angular size units. + + The north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Returns + ------- + string + The north-south semi-axis, in angular size units. + string + The east-west semi-axis, in angular size units. + """ + return super().wcs_size + + @property + def size(self): + """The size, in pixels. + + The width is equal to twice the east-west semi-axis, and the height is equal to twice the north-south semi-axis. + + Returns + ------- + number + The width. + number + The height. + """ + semi_ns, semi_ew = self.semi_axes + return (semi_ew * 2, semi_ns * 2) + + @property + def wcs_size(self): + """The size, in angular size units. + + The width is equal to twice the east-west semi-axis, and the height is equal to twice the north-south semi-axis. + + Returns + ------- + string + The width. + string + The height. + """ + [size] = self.region_set.image.to_angular_size_points([self.size]) + return size + + # SET PROPERTIES + + @validate(Point.SizePoint()) + def set_semi_axes(self, semi_axes): + """Set the semi-axes. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new north-south and east-west semi-axes, in that order. + """ + [semi_axes] = self._from_angular_sizes([semi_axes]) + super().set_size(semi_axes) + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + The width and height will be used to calculate the semi-axes: the north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [size] = self._from_angular_sizes([size]) + width, height = size + super().set_size([height / 2, width / 2]) + + +class PointAnnotation(Region): """A point annotation.""" REGION_TYPE = RegionType.ANNPOINT @@ -1092,24 +1271,48 @@ class PointAnnotation(Annotation): @property def point_shape(self): + """The point shape. + + Returns + ------- + :obj:`carta.constants.PointShape` object + The point shape. + """ return PointShape(self.get_value("pointShape")) @property def point_width(self): + """The point width. + + Returns + ------- + number + The point width, in pixels. + """ return self.get_value("pointWidth") # SET PROPERTIES - @validate(Constant(PointShape)) - def set_point_shape(self, shape): - self.call_action("setPointShape", shape) + @validate(*all_optional(Constant(PointShape), Number())) + def set_point_style(self, point_shape, point_width): + """Set the point style. - @validate(Number()) - def set_point_width(self, width): - self.call_action("setPointWidth", width) + All parameters are optional. Omitted properties will be left unmodified. + Parameters + ---------- + point_shape : {0} + The point shape. + point_width : {1} + The point width, in pixels. + """ + if point_shape is not None: + self.call_action("setPointShape", point_shape) + if point_width is not None: + self.call_action("setPointWidth", point_width) -class TextAnnotation(Annotation, HasFontMixin): + +class TextAnnotation(Region, HasFontMixin, HasRotationMixin): """A text annotation.""" REGION_TYPE = RegionType.ANNTEXT @@ -1117,29 +1320,57 @@ class TextAnnotation(Annotation, HasFontMixin): @property def text(self): + """The text content. + + Returns + ------- + string + The text content. + """ return self.get_value("text") @property def position(self): + """The position of the text in this annotation. + + Returns + ------- + :obj:`carta.constants.TextPosition` + The text position. + """ return TextPosition(self.get_value("position")) # SET PROPERTIES @validate(String()) def set_text(self, text): + """Set the text content. + + Parameters + ---------- + text : {0} + The text content. + """ self.call_action("setText", text) @validate(Constant(TextPosition)) - def set_position(self, position): - self.call_action("setPosition", position) + def set_text_position(self, text_position): + """Set the position of the text in this annotation. + + Parameters + ---------- + text_position : {0} + The position of the text. + """ + self.call_action("setPosition", text_position) -class VectorAnnotation(Annotation, HasPointerMixin, HasEndpointsMixin): +class VectorAnnotation(Region, HasPointerMixin, HasEndpointsMixin): """A vector annotation.""" REGION_TYPE = RegionType.ANNVECTOR -class CompassAnnotation(Annotation, HasFontMixin, HasPointerMixin): +class CompassAnnotation(Region, HasFontMixin, HasPointerMixin): """A compass annotation.""" REGION_TYPE = RegionType.ANNCOMPASS @@ -1147,35 +1378,123 @@ class CompassAnnotation(Annotation, HasFontMixin, HasPointerMixin): @property def labels(self): + """The north and east labels. + + Returns + ------- + string + The north label. + string + The east label. + """ return self.get_value("northLabel"), self.get_value("eastLabel") @property def length(self): + """The length of the compass points, in pixels. + + Returns + ------- + number + The length of the compass points. + """ return self.get_value("length") @property - def text_offsets(self): - return Pt(**self.get_value("northTextOffset")), Pt(**self.get_value("eastTextOffset")) + def label_offsets(self): + """The offsets of the north and east labels. + + Returns + ------- + tuple of two numbers + The offset of the north label, in pixels. + tuple of two numbers + The offset of the east label, in pixels. + """ + return Pt(**self.get_value("northTextOffset")).as_tuple(), Pt(**self.get_value("eastTextOffset")).as_tuple() @property def arrowheads_visible(self): + """The visibility of the north and east arrowheads. + + Returns + ------- + boolean + Whether the north arrowhead is visible. + boolean + Whether the east arrowhead is visible. + """ return self.get_value("northArrowhead"), self.get_value("eastArrowhead") # SET PROPERTIES + def set_size(self, size): + """Set the size. + + The width and height of this annotation cannot be set individually. :obj:`carta.region.CompassAnnotation.set_length` should be used instead. + + Raises + ------ + NotImplementedError + If this function is called. + """ + raise NotImplementedError("Compass annotation width and height cannot be set individually. Please use `set_length` to resize this annotation.") + @validate(*all_optional(String(), String())) def set_label(self, north_label=None, east_label=None): + """Set the north and east labels. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + north_label : {0} + The north label. + east_label : {1} + The east label. + """ if north_label is not None: self.call_action("setLabel", north_label, True) if east_label is not None: self.call_action("setLabel", east_label, False) - @validate(Number()) - def set_length(self, length): + @validate(Size(), NoneOr(Constant(SpatialAxis))) + def set_length(self, length, spatial_axis=None): + """Set the length of the compass points. + + If the length is provided in angular size units, a spatial axis must also be provided in order for the angular size to be converted to pixels. + + Parameters + ---------- + length : {0} + The length, in pixels or angular size units. + spatial_axis : {1} + The spatial axis which should be used to convert angular size units. This parameter is ignored if the length is provided in pixels. + + Raises + ------ + ValueError + If the length is in angular size units, but no spatial axis is provided. + """ + if isinstance(length, str): + if spatial_axis is None: + raise ValueError("Please specify a spatial axis to convert length from angular size units, or use pixels instead.") + length = self.region_set.image.from_angular_size(length, spatial_axis) self.call_action("setLength", length) @validate(*all_optional(Point.NumericPoint(), Point.NumericPoint())) - def set_text_offset(self, north_offset=None, east_offset=None): + def set_label_offset(self, north_offset=None, east_offset=None): + """Set the north and east label offsets. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + north_offset : {0} + The north label offset, in pixels. + east_offset : {1} + The east label offset, in pixels. + """ if north_offset is not None: self.call_action("setNorthTextOffset", north_offset.x, True) self.call_action("setNorthTextOffset", north_offset.y, False) @@ -1185,13 +1504,27 @@ def set_text_offset(self, north_offset=None, east_offset=None): @validate(*all_optional(Boolean(), Boolean())) def set_arrowhead_visible(self, north=None, east=None): + """Set the north and east arrowhead visibility. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + north : {0} + Whether the north arrowhead should be visible. + east : {1} + Whether the east arrowhead should be visible. + """ if north is not None: self.call_action("setNorthArrowhead", north) if east is not None: self.call_action("setEastArrowhead", east) +# TODO TODO TODO set_size also behaves strangely here, and may need to be removed or overridden. +# TODO TODO TODO add a length to HasEndpointsMixin, though? + -class RulerAnnotation(Annotation, HasFontMixin, HasEndpointsMixin): +class RulerAnnotation(Region, HasFontMixin, HasEndpointsMixin): """A ruler annotation.""" REGION_TYPE = RegionType.ANNRULER @@ -1199,27 +1532,73 @@ class RulerAnnotation(Annotation, HasFontMixin, HasEndpointsMixin): @property def auxiliary_lines_visible(self): + """The visibility of the auxiliary lines. + + Returns + ------- + boolean + Whether the auxiliary lines are visible. + """ return self.get_value("auxiliaryLineVisible") @property def auxiliary_lines_dash_length(self): + """The dash length of the auxiliary lines. + + Returns + ------- + number + The dash length of the auxiliary lines, in pixels. + """ return self.get_value("auxiliaryLineDashLength") @property def text_offset(self): - return Pt(*self.get_value("textOffset")) + """The X and Y text offsets. + + Returns + ------- + number + The X offset of the text, in pixels. + number + The Y offset of the text, in pixels. + """ + return Pt(*self.get_value("textOffset")).as_tuple() # SET PROPERTIES - @validate(Boolean()) - def set_auxiliary_lines_visible(self, visible): - self.call_action("setAuxiliaryLineVisible", visible) + @validate(*all_optional(Boolean(), Number())) + def set_auxiliary_lines_style(self, visible=None, dash_length=None): + """Set the auxiliary line style. - @validate(Number()) - def set_auxiliary_lines_dash_length(self, length): - self.call_action("setAuxiliaryLineDashLength", length) + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + visible : {0} + Whether the auxiliary lines should be visible. + dash_length : {1} + The dash length of the auxiliary lines, in pixels. + """ + if visible is not None: + self.call_action("setAuxiliaryLineVisible", visible) + if dash_length is not None: + self.call_action("setAuxiliaryLineDashLength", dash_length) - @validate(Number(), Number()) - def set_text_offset(self, x, y): # TODO pixel only! - self.call_action("setTextOffset", x, True) - self.call_action("setTextOffset", y, False) + @validate(*all_optional(Number(), Number())) + def set_text_offset(self, offset_x=None, offset_y=None): + """Set the text offset. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + offset_x : {0} + The X offset of the text, in pixels. + offset_y : {1} + The Y offset of the text, in pixels. + """ + if offset_x is not None: + self.call_action("setTextOffset", offset_x, True) + if offset_y is not None: + self.call_action("setTextOffset", offset_y, False) diff --git a/carta/util.py b/carta/util.py index 24cebc3..f2a5471 100644 --- a/carta/util.py +++ b/carta/util.py @@ -232,19 +232,59 @@ def __init__(self, x, y): @classmethod def is_pixel(cls, x, y): + """Whether this is a pair of pixel values. + + Returns + ------- + boolean + Whether both values are numeric and should be interpreted as pixels. + """ return isinstance(x, (int, float)) and isinstance(y, (int, float)) @classmethod def is_wcs_coordinate(cls, x, y): + """Whether this is a pair of world coordinates. + + Returns + ------- + boolean + Whether both values are strings and can be parsed as valid world coordinates. + """ return isinstance(x, str) and isinstance(y, str) and WorldCoordinate.valid(x) and WorldCoordinate.valid(y) @classmethod def is_angular_size(cls, x, y): + """Whether this is a pair of angular sizes. + + Returns + ------- + boolean + Whether both values are strings and can be parsed as valid angular sizes. + """ return isinstance(x, str) and isinstance(y, str) and AngularSize.valid(x) and AngularSize.valid(y) def json(self): - """The JSON serialization of this object.""" + """The JSON serialization of this object. + + This is the format in which these values should be sent to the frontend. + + Returns + ------- + dict + A dictionary which can be coerced to a 2D point object in the frontend after serialization and deserialization. + """ return {"x": self.x, "y": self.y} def as_tuple(self): + """The tuple representation of this object. + + This is the format in which these values should be returned to the user. + + Returns + ------- + number or string + The X value. + number or string + The Y value. + """ return self.x, self.y From f53c80d109f373b180ccb732b698c046225b6b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 4 Sep 2023 21:59:55 +0200 Subject: [PATCH 18/50] Modified angular size units to allow negative sizes; fixed missing and inconsistent line region size functions --- carta/image.py | 2 +- carta/region.py | 177 +++++++++++++++++++++++++++++++++++++++++--- carta/units.py | 38 +++++++++- tests/test_units.py | 17 +++-- 4 files changed, 216 insertions(+), 18 deletions(-) diff --git a/carta/image.py b/carta/image.py index 0feb00e..3abfef8 100644 --- a/carta/image.py +++ b/carta/image.py @@ -729,7 +729,7 @@ def from_angular_size(self, size, axis): float The pixel size. """ - arcsec = AngularSize.from_string(size).arcsec() + arcsec = AngularSize.from_string(size).arcsec if axis == SpatialAxis.X: return self.call_action("getImageXValueFromArcsec", arcsec) if axis == SpatialAxis.Y: diff --git a/carta/region.py b/carta/region.py index d0373b9..adb0656 100644 --- a/carta/region.py +++ b/carta/region.py @@ -4,10 +4,12 @@ """ import posixpath +import math from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse, CartaValidationFailed from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont, SpatialAxis from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Union, Size +from .units import AngularSize class RegionSet(BasePathMixin): @@ -31,14 +33,22 @@ def __init__(self, image): self.session = image.session self._base_path = f"{image._base_path}.regionSet" - def list(self): + @validate(NoneOr(Boolean())) + def list(self, ignore_cursor=True): """Return the list of regions associated with this image. + + Parameters + ---------- + ignore_cursor : {0} + Ignore the cursor region. This is set by default. Returns ------- list of :obj:`carta.region.Region` objects. """ region_list = self.get_value("regionList") + if ignore_cursor: + region_list = region_list[1:] return Region.from_list(self, region_list) @validate(Number()) @@ -423,7 +433,7 @@ def add_ruler(self, start, end, name=""): def clear(self): """Delete all regions except for the cursor region.""" - for region in self.list()[1:]: + for region in self.list(): region.delete() @@ -936,6 +946,29 @@ def wcs_endpoints(self): The endpoints. """ return self.region_set.image.to_world_coordinate_points(self.control_points) + + @property + def length(self): + """The Euclidean distance between the endpoints, in pixels. + + Returns + ------- + float + The length. + """ + return math.hypot(*self.size) + + @property + def wcs_length(self): + """The Euclidean distance between the endpoints, in angular size units. + + Returns + ------- + float + The length. + """ + arcsec_size = [AngularSize.from_string(s).arcsec for s in self.wcs_size] + return str(AngularSize.from_arcsec(math.hypot(*arcsec_size))) # SET PROPERTIES @@ -960,6 +993,22 @@ def set_endpoints(self, start=None, end=None): if end is not None: [end] = self.region_set._from_world_coordinates([end]) self.set_control_point(1, end) + + @validate(Size()) + def set_length(self, length): + """Update the length. + + Parameters + ---------- + length : {0} + The new length, in pixels or angular size units. + """ + if isinstance(length, str): + length = self.length * AngularSize.from_string(length).arcsec / self.wcs_length + + rad = math.radians(self.rotation) + + Region.set_size(self, (length * math.sin(rad), -1 * length * math.cos(rad))) class HasFontMixin: @@ -1077,6 +1126,21 @@ def set_pointer_style(self, pointer_width=None, pointer_length=None): class LineRegion(Region, HasEndpointsMixin, HasRotationMixin): """A line region or annotation.""" REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [size] = self.region_set._from_angular_sizes([size]) + sx, sy = size + Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size class PolylineRegion(Region, HasVerticesMixin): @@ -1158,7 +1222,7 @@ def set_corners(self, bottom_left=None, top_right=None): size = Pt(tr.x - bl.x, tr.y - bl.y) center = (bl.x + (size.x / 2), bl.y + (size.y / 2)) - self.set_control_points(center, size.as_tuple()) + self.set_control_points([center, size.as_tuple()]) class EllipticalRegion(Region, HasRotationMixin): @@ -1242,7 +1306,7 @@ def set_semi_axes(self, semi_axes): size : {0} The new north-south and east-west semi-axes, in that order. """ - [semi_axes] = self._from_angular_sizes([semi_axes]) + [semi_axes] = self.region_set._from_angular_sizes([semi_axes]) super().set_size(semi_axes) @validate(Point.SizePoint()) @@ -1258,7 +1322,7 @@ def set_size(self, size): size : {0} The new width and height, in that order. """ - [size] = self._from_angular_sizes([size]) + [size] = self.region_set._from_angular_sizes([size]) width, height = size super().set_size([height / 2, width / 2]) @@ -1365,9 +1429,24 @@ def set_text_position(self, text_position): self.call_action("setPosition", text_position) -class VectorAnnotation(Region, HasPointerMixin, HasEndpointsMixin): +class VectorAnnotation(Region, HasPointerMixin, HasEndpointsMixin, HasRotationMixin): """A vector annotation.""" REGION_TYPE = RegionType.ANNVECTOR + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [size] = self.region_set._from_angular_sizes([size]) + sx, sy = size + Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size class CompassAnnotation(Region, HasFontMixin, HasPointerMixin): @@ -1520,9 +1599,6 @@ def set_arrowhead_visible(self, north=None, east=None): if east is not None: self.call_action("setEastArrowhead", east) -# TODO TODO TODO set_size also behaves strangely here, and may need to be removed or overridden. -# TODO TODO TODO add a length to HasEndpointsMixin, though? - class RulerAnnotation(Region, HasFontMixin, HasEndpointsMixin): """A ruler annotation.""" @@ -1565,7 +1641,90 @@ def text_offset(self): """ return Pt(*self.get_value("textOffset")).as_tuple() + @property + def rotation(self): + """The rotation, in degrees. + + Returns + ------- + number + The rotation. + """ + ((sx, sy), (ex, ey)) = self.endpoints + rad = math.atan((ex - sx) / (sy - ey)) + rotation = math.degrees(rad) + if ey > sy: + rotation += 180 + rotation = (rotation + 360) % 360 + return rotation + # SET PROPERTIES + + @validate(Point.CoordinatePoint()) + def set_center(self, center): + """Set the center position. + + Both image and world coordinates are accepted, but both values must match. + + Parameters + ---------- + center : {0} + The new center position. + """ + [center] = self.region_set._from_world_coordinates([center]) + cx, cy = center + + rad = math.radians(self.rotation) + dx = math.hypot(*self.size) * math.sin(rad) + dy = math.hypot(*self.size) * -1 * math.cos(rad) + + start = cx - dx / 2, cy - dy / 2 + end = cx + dx / 2, cy + dy / 2 + + self.set_control_points([start, end]) + + @validate(Number()) + def set_rotation(self, rotation): + """Set the rotation. + + Parameters + ---------- + angle : {0} + The new rotation, in degrees. + """ + rotation = rotation + 360 % 360 + + cx, cy = self.center + + rad = math.radians(rotation) + dx = math.hypot(*self.size) * math.sin(rad) + dy = math.hypot(*self.size) * -1 * math.cos(rad) + + start = cx - dx / 2, cy - dy / 2 + end = cx + dx / 2, cy + dy / 2 + + self.set_control_points([start, end]) + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [size] = self.region_set._from_angular_sizes([size]) + + cx, cy = self.center + dx, dy = size + + start = cx - dx / 2, cy - dy / 2 + end = cx + dx / 2, cy + dy / 2 + + self.set_control_points([end, start]) # reversed for consistency with returned size @validate(*all_optional(Boolean(), Number())) def set_auxiliary_lines_style(self, visible=None, dash_length=None): diff --git a/carta/units.py b/carta/units.py index d168500..d06ce06 100644 --- a/carta/units.py +++ b/carta/units.py @@ -34,8 +34,8 @@ def _update_unit_regex(cls, units): symbols = {u for u in units if len(u) <= 1} words = units - symbols - cls.SYMBOL_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)({'|'.join(symbols)})$" - cls.WORD_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)\s*({'|'.join(words)})$" + cls.SYMBOL_UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)({'|'.join(symbols)})$" + cls.WORD_UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)\s*({'|'.join(words)})$" @classmethod def valid(cls, value): @@ -89,6 +89,39 @@ def from_string(cls, value): if cls is AngularSize: return cls.FORMATS[unit](float(value)) return cls(float(value)) + + @classmethod + def from_arcsec(cls, arcsec): + """Construct an angular size object from a numeric value in arcseconds. + + If this method is called on the parent :obj:`carta.units.AngularSize` class, it will automatically guess the most appropriate unit subclass. If it is called on a unit subclass, it will return an instance of that subclass. + + If this method is called on the This method automatically guesses the most appropriate unit. + + Parameters + ---------- + arcsec : float + The angular size in arcseconds. + + Returns + ------- + :obj:`carta.units.AngularSize` object + The angular size object. + """ + + if cls is AngularSize: + if arcsec < 0.002: + unit = MilliarcsecSize + elif arcsec < 120: + unit = ArcsecSize + elif arcsec < 7200: + unit = ArcminSize + else: + unit = DegreesSize + else: + unit = cls + + return unit(arcsec / unit.ARCSEC_FACTOR) def __str__(self): if type(self) is AngularSize: @@ -96,6 +129,7 @@ def __str__(self): value = self.value * self.FACTOR return f"{value:g}{self.OUTPUT_UNIT}" + @property def arcsec(self): """The numeric value in arcseconds. diff --git a/tests/test_units.py b/tests/test_units.py index a004649..15dd198 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -29,11 +29,11 @@ def test_class_classmethods_have_docstrings(member): ("123 deg", True), ("123 degree", True), ("123 degrees", True), + ("-123deg", True), ("123 arcmin", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_degrees_size_valid(size, valid): @@ -53,11 +53,11 @@ def test_degrees_size_valid(size, valid): ("123 amin", True), ("123'", True), ("123′", True), + ("-123arcmin", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_arcmin_size_valid(size, valid): @@ -78,11 +78,11 @@ def test_arcmin_size_valid(size, valid): ("123", True), ("123\"", True), ("123″", True), + ("-123", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_arcsec_size_valid(size, valid): @@ -100,11 +100,11 @@ def test_arcsec_size_valid(size, valid): ("123 milliarcsecond", True), ("123 milliarcsec", True), ("123 mas", True), + ("-123mas", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_milliarcsec_size_valid(size, valid): @@ -124,11 +124,11 @@ def test_milliarcsec_size_valid(size, valid): ("123 microarcsec", True), ("123 µas", True), ("123 uas", True), + ("-123uas", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_microarcsec_size_valid(size, valid): @@ -148,6 +148,7 @@ def test_microarcsec_size_valid(size, valid): ("123 amin", "123'"), ("123'", "123'"), ("123′", "123'"), + ("-123'", "-123'"), ]) def test_arcmin_size_from_string(size, norm): assert str(ArcminSize.from_string(size)) == norm @@ -166,6 +167,7 @@ def test_arcmin_size_from_string(size, norm): ("123", "123\""), ("123\"", "123\""), ("123″", "123\""), + ("-123", "-123\""), ]) def test_arcsec_size_from_string(size, norm): assert str(ArcsecSize.from_string(size)) == norm @@ -179,6 +181,7 @@ def test_arcsec_size_from_string(size, norm): ("123 deg", "123deg"), ("123 degree", "123deg"), ("123 degrees", "123deg"), + ("-123deg", "-123deg"), ]) def test_degrees_size_from_string(size, norm): assert str(DegreesSize.from_string(size)) == norm @@ -194,6 +197,7 @@ def test_degrees_size_from_string(size, norm): ("123 milliarcsecond", "0.123\""), ("123 milliarcsec", "0.123\""), ("123 mas", "0.123\""), + ("-123mas", "-0.123\""), ]) def test_milliarcsec_size_from_string(size, norm): assert str(MilliarcsecSize.from_string(size)) == norm @@ -211,6 +215,7 @@ def test_milliarcsec_size_from_string(size, norm): ("123 microarcsec", "0.000123\""), ("123 µas", "0.000123\""), ("123 uas", "0.000123\""), + ("-123uas", "-0.000123\""), ]) def test_microarcsec_size_from_string(size, norm): assert str(MicroarcsecSize.from_string(size)) == norm @@ -230,7 +235,7 @@ def test_angular_size_from_string_one_invalid(clazz, size): assert "not in a recognized" in str(e.value) -@pytest.mark.parametrize("size", ["123cm", "abc", "-123", "123px"]) +@pytest.mark.parametrize("size", ["123cm", "abc", "123px"]) def test_angular_size_from_string_all_invalid(size): with pytest.raises(ValueError) as e: AngularSize.from_string(size) From 9575d7489a8d3c20d461dd84b1abb6a96b21bfc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 5 Sep 2023 12:58:03 +0200 Subject: [PATCH 19/50] formatting pass --- carta/region.py | 56 ++++++++++++++++++++++++------------------------- carta/units.py | 14 ++++++------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/carta/region.py b/carta/region.py index adb0656..4e6cdc8 100644 --- a/carta/region.py +++ b/carta/region.py @@ -36,7 +36,7 @@ def __init__(self, image): @validate(NoneOr(Boolean())) def list(self, ignore_cursor=True): """Return the list of regions associated with this image. - + Parameters ---------- ignore_cursor : {0} @@ -946,22 +946,22 @@ def wcs_endpoints(self): The endpoints. """ return self.region_set.image.to_world_coordinate_points(self.control_points) - + @property def length(self): """The Euclidean distance between the endpoints, in pixels. - + Returns ------- float The length. """ return math.hypot(*self.size) - + @property def wcs_length(self): """The Euclidean distance between the endpoints, in angular size units. - + Returns ------- float @@ -993,11 +993,11 @@ def set_endpoints(self, start=None, end=None): if end is not None: [end] = self.region_set._from_world_coordinates([end]) self.set_control_point(1, end) - + @validate(Size()) def set_length(self, length): """Update the length. - + Parameters ---------- length : {0} @@ -1005,9 +1005,9 @@ def set_length(self, length): """ if isinstance(length, str): length = self.length * AngularSize.from_string(length).arcsec / self.wcs_length - + rad = math.radians(self.rotation) - + Region.set_size(self, (length * math.sin(rad), -1 * length * math.cos(rad))) @@ -1126,7 +1126,7 @@ def set_pointer_style(self, pointer_width=None, pointer_length=None): class LineRegion(Region, HasEndpointsMixin, HasRotationMixin): """A line region or annotation.""" REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) - + @validate(Point.SizePoint()) def set_size(self, size): """Set the size. @@ -1140,7 +1140,7 @@ def set_size(self, size): """ [size] = self.region_set._from_angular_sizes([size]) sx, sy = size - Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size + Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size class PolylineRegion(Region, HasVerticesMixin): @@ -1432,7 +1432,7 @@ def set_text_position(self, text_position): class VectorAnnotation(Region, HasPointerMixin, HasEndpointsMixin, HasRotationMixin): """A vector annotation.""" REGION_TYPE = RegionType.ANNVECTOR - + @validate(Point.SizePoint()) def set_size(self, size): """Set the size. @@ -1446,7 +1446,7 @@ def set_size(self, size): """ [size] = self.region_set._from_angular_sizes([size]) sx, sy = size - Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size + Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size class CompassAnnotation(Region, HasFontMixin, HasPointerMixin): @@ -1657,9 +1657,9 @@ def rotation(self): rotation += 180 rotation = (rotation + 360) % 360 return rotation - + # SET PROPERTIES - + @validate(Point.CoordinatePoint()) def set_center(self, center): """Set the center position. @@ -1673,16 +1673,16 @@ def set_center(self, center): """ [center] = self.region_set._from_world_coordinates([center]) cx, cy = center - + rad = math.radians(self.rotation) dx = math.hypot(*self.size) * math.sin(rad) dy = math.hypot(*self.size) * -1 * math.cos(rad) - + start = cx - dx / 2, cy - dy / 2 end = cx + dx / 2, cy + dy / 2 - + self.set_control_points([start, end]) - + @validate(Number()) def set_rotation(self, rotation): """Set the rotation. @@ -1693,18 +1693,18 @@ def set_rotation(self, rotation): The new rotation, in degrees. """ rotation = rotation + 360 % 360 - + cx, cy = self.center - + rad = math.radians(rotation) dx = math.hypot(*self.size) * math.sin(rad) dy = math.hypot(*self.size) * -1 * math.cos(rad) - + start = cx - dx / 2, cy - dy / 2 end = cx + dx / 2, cy + dy / 2 - + self.set_control_points([start, end]) - + @validate(Point.SizePoint()) def set_size(self, size): """Set the size. @@ -1717,14 +1717,14 @@ def set_size(self, size): The new width and height, in that order. """ [size] = self.region_set._from_angular_sizes([size]) - + cx, cy = self.center dx, dy = size - + start = cx - dx / 2, cy - dy / 2 end = cx + dx / 2, cy + dy / 2 - - self.set_control_points([end, start]) # reversed for consistency with returned size + + self.set_control_points([end, start]) # reversed for consistency with returned size @validate(*all_optional(Boolean(), Number())) def set_auxiliary_lines_style(self, visible=None, dash_length=None): diff --git a/carta/units.py b/carta/units.py index d06ce06..d7b45b1 100644 --- a/carta/units.py +++ b/carta/units.py @@ -89,26 +89,26 @@ def from_string(cls, value): if cls is AngularSize: return cls.FORMATS[unit](float(value)) return cls(float(value)) - + @classmethod def from_arcsec(cls, arcsec): """Construct an angular size object from a numeric value in arcseconds. - + If this method is called on the parent :obj:`carta.units.AngularSize` class, it will automatically guess the most appropriate unit subclass. If it is called on a unit subclass, it will return an instance of that subclass. - + If this method is called on the This method automatically guesses the most appropriate unit. - + Parameters ---------- arcsec : float The angular size in arcseconds. - + Returns ------- :obj:`carta.units.AngularSize` object The angular size object. """ - + if cls is AngularSize: if arcsec < 0.002: unit = MilliarcsecSize @@ -120,7 +120,7 @@ def from_arcsec(cls, arcsec): unit = DegreesSize else: unit = cls - + return unit(arcsec / unit.ARCSEC_FACTOR) def __str__(self): From a90cee621240e3717a4e37d10b5ffd915cbe96c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 20 Oct 2023 01:40:34 +0200 Subject: [PATCH 20/50] Halfway through region tests --- carta/util.py | 3 + tests/test_region.py | 540 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 tests/test_region.py diff --git a/carta/util.py b/carta/util.py index f2a5471..afeb201 100644 --- a/carta/util.py +++ b/carta/util.py @@ -230,6 +230,9 @@ def __init__(self, x, y): self.x = x self.y = y + def __eq__(self, other): + return self.x == other.x and self.y == other.y + @classmethod def is_pixel(cls, x, y): """Whether this is a pair of pixel values. diff --git a/tests/test_region.py b/tests/test_region.py new file mode 100644 index 0000000..3e42fef --- /dev/null +++ b/tests/test_region.py @@ -0,0 +1,540 @@ +import pytest +import inspect + +from carta.session import Session +from carta.image import Image +import carta.region # For docstring inspection +from carta.region import Region +from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT +from carta.util import Point as Pt, Macro + +# FIXTURES + +# Session and image mocks + + +@pytest.fixture +def session(): + """Return a session object. + + The session's protocol is set to None, so any tests that use this must also mock the session's call_action and/or higher-level functions which call it. + """ + return Session(0, None) + + +@pytest.fixture +def image(session): + """Return an image object which uses the session fixture. + """ + return Image(session, 0) + + +@pytest.fixture +def mock_session_call_action(session, mocker): + """Return a mock for session's call_action.""" + return mocker.patch.object(session, "call_action") + + +@pytest.fixture +def mock_session_method(session, mocker): + """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" + def func(method_name, return_values): + return mocker.patch.object(session, method_name, side_effect=return_values) + return func + + +@pytest.fixture +def mock_image_method(image, mocker): + """Return a helper function to mock the return value(s) of an image method using a simple syntax.""" + def func(method_name, return_values): + return mocker.patch.object(image, method_name, side_effect=return_values) + return func + + +# Regionset mocks + + +@pytest.fixture +def mock_regionset_get_value(image, mocker): + """Return a mock for image.regions' get_value.""" + return mocker.patch.object(image.regions, "get_value") + + +@pytest.fixture +def mock_regionset_call_action(image, mocker): + """Return a mock for image.regions' call_action.""" + return mocker.patch.object(image.regions, "call_action") + + +@pytest.fixture +def mock_regionset_method(image, mocker): + """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" + def func(method_name, return_values): + return mocker.patch.object(image.regions, method_name, side_effect=return_values) + return func + + +# The region-specific mocks are all factories, so that they can be used to mock different region subclasses (specified by region type) + + +@pytest.fixture +def region(image): + """Return a factory for a new region object which uses the image fixture, specifying a class and/or ID. + """ + def func(region_type=None, region_id=0): + clazz = Region if region_type is None else Region.region_class(region_type) + return clazz(image.regions, region_id) + return func + + +@pytest.fixture +def mock_get_value(mocker): + """Return a factory for a mock for a region object's get_value.""" + def func(region, mock_value=None): + return mocker.patch.object(region, "get_value", return_value=mock_value) + return func + + +@pytest.fixture +def mock_call_action(mocker): + """Return a factory for a mock for a region object's call_action.""" + def func(region): + return mocker.patch.object(region, "call_action") + return func + + +@pytest.fixture +def mock_property(mocker): + """Return a factory for a mock for a region object's decorated property.""" + def func(region, property_name, mock_value=None): + class_name = region.__class__.__name__ # Is this going to work? Do we need to import it? + return mocker.patch(f"carta.region.{class_name}.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) + return func + + +@pytest.fixture +def mock_method(mocker): + """Return a factory for a mock for a region object's method.""" + def func(region, method_name, return_values): + return mocker.patch.object(region, method_name, side_effect=return_values) + return func + + +# TESTS + +# DOCSTRINGS + + +def find_classes(): + for name, clazz in inspect.getmembers(carta.region, inspect.isclass): + if not clazz.__module__ == 'carta.region': + continue + yield clazz + + +def find_methods(classes): + for clazz in classes: + for name, member in inspect.getmembers(clazz, lambda m: inspect.isfunction(m) or inspect.ismethod(m)): + if not member.__module__ == 'carta.region': + continue + if not member.__qualname__.split('.')[0] == clazz.__name__: + continue + if member.__name__.startswith('__'): + continue + yield member + + +def find_properties(classes): + for clazz in classes: + for name, member in inspect.getmembers(clazz, lambda m: isinstance(m, property)): + if not member.fget.__module__ == 'carta.region': + continue + if not member.fget.__qualname__.split('.')[0] == clazz.__name__: + continue + if member.fget.__name__.startswith('__'): + continue + yield member.fget + + +@pytest.mark.parametrize("member", find_classes()) +def test_region_classes_have_docstrings(member): + assert member.__doc__ is not None + + +@pytest.mark.parametrize("member", find_methods(find_classes())) +def test_region_methods_have_docstrings(member): + assert member.__doc__ is not None + + +@pytest.mark.parametrize("member", find_properties(find_classes())) +def test_region_properties_have_docstrings(member): + assert member.__doc__ is not None + +# REGION SET + + +@pytest.mark.parametrize("ignore_cursor,expected_items", [ + (True, [2, 3]), + (False, [1, 2, 3]), +]) +def test_regionset_list(mocker, image, mock_regionset_get_value, ignore_cursor, expected_items): + mock_regionset_get_value.side_effect = [[1, 2, 3]] + mock_from_list = mocker.patch.object(Region, "from_list") + image.regions.list(ignore_cursor) + mock_regionset_get_value.assert_called_with("regionList") + mock_from_list.assert_called_with(image.regions, expected_items) + + +def test_regionset_get(mocker, image, mock_regionset_get_value): + mock_regionset_get_value.side_effect = [RT.RECTANGLE] + mock_existing = mocker.patch.object(Region, "existing") + image.regions.get(1) + mock_regionset_get_value.assert_called_with("regionMap[1]", return_path="regionType") + mock_existing.assert_called_with(RT.RECTANGLE, image.regions, 1) + + +def test_regionset_import_from(mocker, image, mock_session_method, mock_session_call_action): + mock_session_method("resolve_file_path", ["/path/to/directory/"]) + mock_session_call_action.side_effect = [FT.CRTF, None] + image.regions.import_from("input_region_file") + mock_session_call_action.assert_has_calls([ + mocker.call("backendService.getRegionFileInfo", "/path/to/directory/", "input_region_file", return_path="fileInfo.type"), + mocker.call("importRegion", "/path/to/directory/", "input_region_file", FT.CRTF, image._frame), + ]) + + +@pytest.mark.parametrize("coordinate_type", [CT.PIXEL, CT.WORLD]) +@pytest.mark.parametrize("file_type", [FT.CRTF, FT.DS9_REG]) +@pytest.mark.parametrize("region_ids,expected_region_ids", [(None, [2, 3, 4]), ([2, 3], [2, 3]), ([4], [4])]) +def test_regionset_export_to(mocker, image, mock_session_method, mock_session_call_action, mock_regionset_get_value, coordinate_type, file_type, region_ids, expected_region_ids): + mock_session_method("resolve_file_path", ["/path/to/directory/"]) + mock_regionset_get_value.side_effect = [[{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]] + image.regions.export_to("output_region_file", coordinate_type, file_type, region_ids) + mock_session_call_action.assert_called_with("exportRegions", "/path/to/directory/", "output_region_file", coordinate_type, file_type, expected_region_ids, image._frame) + + +def test_regionset_add_region(mocker, image): + mock_new = mocker.patch.object(Region, "new") + image.regions.add_region(RT.RECTANGLE, [(10, 10), (100, 100)], 90, "name") + mock_new.assert_called_with(image.regions, RT.RECTANGLE, [(10, 10), (100, 100)], 90, "name") + + +@pytest.mark.parametrize("func,args,kwargs,expected_args,expected_kwargs", [ + ("add_point", [(10, 10)], {}, [RT.POINT, [(10, 10)]], {"name": ""}), + ("add_point", [("10", "10")], {}, [RT.POINT, [(10, 10)]], {"name": ""}), + ("add_point", [(10, 10)], {"annotation": True}, [RT.ANNPOINT, [(10, 10)]], {"name": ""}), + ("add_point", [(10, 10)], {"name": "my region"}, [RT.POINT, [(10, 10)]], {"name": "my region"}), + + ("add_rectangle", [(10, 10), (20, 20)], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_rectangle", [("10", "10"), ("20", "20")], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_rectangle", [("10", "10"), (20, 20)], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_rectangle", [(10, 10), ("20", "20")], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_rectangle", [(10, 10), (20, 20)], {"annotation": True}, [RT.ANNRECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_rectangle", [(10, 10), (20, 20)], {"name": "my region"}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, "my region"], {}), + ("add_rectangle", [(10, 10), (20, 20)], {"rotation": 45}, [RT.RECTANGLE, [(10, 10), (20, 20)], 45, ""], {}), + + ("add_ellipse", [(10, 10), (20, 20)], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_ellipse", [("10", "10"), ("20", "20")], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_ellipse", [("10", "10"), (20, 20)], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_ellipse", [(10, 10), ("20", "20")], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_ellipse", [(10, 10), (20, 20)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), + ("add_ellipse", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, "my region"], {}), + ("add_ellipse", [(10, 10), (20, 20)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (20, 20)], 45, ""], {}), + + ("add_polygon", [[(10, 10), (20, 20), (30, 30)]], {}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polygon", [[("10", "10"), ("20", "20"), ("30", "30")]], {}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polygon", [[(10, 10), (20, 20), (30, 30)]], {"annotation": True}, [RT.ANNPOLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polygon", [[(10, 10), (20, 20), (30, 30)]], {"name": "my region"}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": "my region"}), + + ("add_line", [(10, 10), (20, 20)], {}, [RT.LINE, [(10, 10), (20, 20)]], {"name": ""}), + ("add_line", [("10", "10"), ("20", "20")], {}, [RT.LINE, [(10, 10), (20, 20)]], {"name": ""}), + ("add_line", [(10, 10), (20, 20)], {"annotation": True}, [RT.ANNLINE, [(10, 10), (20, 20)]], {"name": ""}), + ("add_line", [(10, 10), (20, 20)], {"name": "my region"}, [RT.LINE, [(10, 10), (20, 20)]], {"name": "my region"}), + + ("add_polyline", [[(10, 10), (20, 20), (30, 30)]], {}, [RT.POLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polyline", [[("10", "10"), ("20", "20"), ("30", "30")]], {}, [RT.POLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polyline", [[(10, 10), (20, 20), (30, 30)]], {"annotation": True}, [RT.ANNPOLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polyline", [[(10, 10), (20, 20), (30, 30)]], {"name": "my region"}, [RT.POLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": "my region"}), + + ("add_vector", [(10, 10), (20, 20)], {}, [RT.ANNVECTOR, [(10, 10), (20, 20)]], {"name": ""}), + ("add_vector", [("10", "10"), ("20", "20")], {}, [RT.ANNVECTOR, [(10, 10), (20, 20)]], {"name": ""}), + ("add_vector", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ANNVECTOR, [(10, 10), (20, 20)]], {"name": "my region"}), + + ("add_text", [(10, 10), (20, 20), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [("10", "10"), ("20", "20"), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [("10", "10"), (20, 20), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [(10, 10), ("20", "20"), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [(10, 10), (20, 20), "text goes here"], {"name": "my region"}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, "my region"], {}), + ("add_text", [(10, 10), (20, 20), "text goes here"], {"rotation": 45}, [RT.ANNTEXT, [(10, 10), (20, 20)], 45, ""], {}), + + ("add_compass", [(10, 10), 100], {}, [RT.ANNCOMPASS, [(10, 10), (100, 100)]], {"name": ""}), + ("add_compass", [("10", "10"), 100], {}, [RT.ANNCOMPASS, [(10, 10), (100, 100)]], {"name": ""}), + ("add_compass", [(10, 10), 100], {"name": "my region"}, [RT.ANNCOMPASS, [(10, 10), (100, 100)]], {"name": "my region"}), + + ("add_ruler", [(10, 10), (20, 20)], {}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": ""}), + ("add_ruler", [("10", "10"), ("20", "20")], {}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": ""}), + ("add_ruler", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": "my region"}), +]) +def test_regionset_add_region_with_type(mocker, image, mock_regionset_method, region, func, args, kwargs, expected_args, expected_kwargs): + mock_add_region = mock_regionset_method("add_region", None) + + if func == "add_text": + text_annotation = region(region_type=RT.ANNTEXT) + mock_add_region.return_value = text_annotation + mock_set_text = mocker.patch.object(text_annotation, "set_text") + + mock_regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) + mock_regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) + + getattr(image.regions, func)(*args, **kwargs) + + mock_add_region.assert_called_with(*expected_args, **expected_kwargs) + + if func == "add_text": + mock_set_text.assert_called_with(args[2]) + + +def test_regionset_clear(mocker, image, mock_regionset_method, mock_method, region): + regionlist = [region(), region(), region()] + mock_deletes = [mock_method(r, "delete", None) for r in regionlist] + mock_regionset_method("list", [regionlist]) + + image.regions.clear() + + for m in mock_deletes: + m.assert_called_with() + + +def test_region_type(region, mock_get_value): + reg = region() + reg_mock_get_value = mock_get_value(reg, 3) + + region_type = reg.region_type + + reg_mock_get_value.assert_called_with("regionType") + assert region_type == RT.RECTANGLE + + +def test_center(region, mock_get_value): + reg = region() + reg_mock_get_value = mock_get_value(reg, {"x": 20, "y": 30}) + + center = reg.center + + reg_mock_get_value.assert_called_with("center") + assert center == (20, 30) + + +def test_wcs_center(region, mock_property, mock_image_method): + reg = region() + mock_property(reg, "center", (20, 30)) + mock_to_wcs = mock_image_method("to_world_coordinate_points", lambda l: [(str(x), str(y)) for (x, y) in l]) + + wcs_center = reg.wcs_center + + mock_to_wcs.assert_called_with([(20, 30)]) + assert wcs_center == ("20", "30") + + +@pytest.mark.parametrize("region_type", [t for t in RT]) +def test_size(region, mock_get_value, region_type): + reg = region(region_type) + + if region_type in (RT.POINT, RT.ANNPOINT): + reg_mock_get_value = mock_get_value(reg, None) + else: + reg_mock_get_value = mock_get_value(reg, {"x": 20, "y": 30}) + + size = reg.size + + reg_mock_get_value.assert_called_with("size") + if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): + assert size == (60, 40) # The frontend size returned for an ellipse is the semi-axes, which we double and swap + elif region_type in (RT.POINT, RT.ANNPOINT): + assert size is None # Test that returned null/undefined size for a point is converted to None as expected + else: + assert size == (20, 30) + + +@pytest.mark.parametrize("region_type", [t for t in RT]) +def test_wcs_size(region, mock_get_value, mock_property, mock_image_method, region_type): + reg = region(region_type) + + if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): + # Bypasses wcsSize to call own (overridden) size and converts to angular units + mock_property(reg, "size", (20, 30)) + mock_to_ang = mock_image_method("to_angular_size_points", lambda l: [(str(x), str(y)) for (x, y) in l]) + elif region_type in (RT.POINT, RT.ANNPOINT): + # Simulate undefined size + reg_mock_get_value = mock_get_value(reg, {"x": None, "y": None}) + else: + reg_mock_get_value = mock_get_value(reg, {"x": "20", "y": "30"}) + + size = reg.wcs_size + + if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): + mock_to_ang.assert_called_with([(20, 30)]) + assert size == ("20", "30") + elif region_type in (RT.POINT, RT.ANNPOINT): + reg_mock_get_value.assert_called_with("wcsSize") + assert size is None + else: + reg_mock_get_value.assert_called_with("wcsSize") + assert size == ("20\"", "30\"") + + +def test_control_points(region, mock_get_value): + reg = region() + mock_get_value(reg, [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}]) + points = reg.control_points + assert points == [(1, 2), (3, 4), (5, 6)] + + +@pytest.mark.parametrize("method_name,value_name", [ + ("name", "name"), + ("color", "color"), + ("line_width", "lineWidth"), + ("dash_length", "dashLength"), +]) +def test_simple_properties(region, mock_get_value, method_name, value_name): + reg = region() + mock_value_getter = mock_get_value(reg, "dummy") + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == "dummy" + +# Overridden behaviour for Ruler + + +@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), Pt(20, 30)), + (("20", "30"), Pt(20, 30)), +]) +def test_set_center(region, mock_regionset_method, mock_call_action, mock_method, mock_property, region_type, value, expected_value): + reg = region(region_type) + mock_regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) + + if region_type == RT.ANNRULER: + mock_property(reg, "size", (-10, -10)) + mock_property(reg, "rotation", 135) + mock_set_points = mock_method(reg, "set_control_points", None) + else: + mock_call = mock_call_action(reg) + + reg.set_center(value) + + if region_type == RT.ANNRULER: + mock_set_points.assert_called_with([(15, 25), (25, 35)]) + else: + mock_call.assert_called_with("setCenter", expected_value) + + +@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), Pt(20, 30)), + (("20", "30"), Pt(20, 30)), +]) +def test_set_size(region, mock_regionset_method, mock_call_action, mock_method, mock_property, region_type, value, expected_value): + reg = region(region_type) + + if region_type == RT.ANNCOMPASS: + with pytest.raises(NotImplementedError) as e: + reg.set_size(value) + assert "Compass annotation width and height cannot be set individually" in str(e.value) + return + + mock_regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) + + if region_type == RT.ANNRULER: + mock_set_points = mock_method(reg, "set_control_points", None) + mock_property(reg, "center", (10, 10)) + else: + mock_call = mock_call_action(reg) + + reg.set_size(value) + + if region_type == RT.ANNRULER: + mock_set_points.assert_called_with([(20.0, 25.0), (0.0, -5.0)]) + elif region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR}: + mock_call.assert_called_with("setSize", Pt(- expected_value.x, - expected_value.y)) + elif region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: + mock_call.assert_called_with("setSize", Pt(expected_value.y / 2, expected_value.x / 2)) + else: + mock_call.assert_called_with("setSize", expected_value) + + +def test_set_control_point(region, mock_call_action): + reg = region() + mock_call = mock_call_action(reg) + reg.set_control_point(3, (20, 30)) + mock_call.assert_called_with("setControlPoint", 3, Pt(20, 30)) + + +def test_set_control_points(region, mock_call_action): + reg = region() + mock_call = mock_call_action(reg) + reg.set_control_points([(20, 30), (40, 50)]) + mock_call.assert_called_with("setControlPoints", [Pt(20, 30), Pt(40, 50)]) + + +def test_set_name(region, mock_call_action): + reg = region() + mock_call = mock_call_action(reg) + reg.set_name("My region name") + mock_call.assert_called_with("setName", "My region name") + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + (["blue", 2, 3], {}, [("setColor", "blue"), ("setLineWidth", 2), ("setDashLength", 3)]), + (["blue"], {"dash_length": 3}, [("setColor", "blue"), ("setDashLength", 3)]), + ([], {"line_width": 2}, [("setLineWidth", 2)]), +]) +def test_set_line_style(mocker, region, mock_call_action, args, kwargs, expected_calls): + reg = region() + mock_call = mock_call_action(reg) + reg.set_line_style(*args, **kwargs) + mock_call.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +def test_lock(region, mock_call_action): + reg = region() + mock_call = mock_call_action(reg) + reg.lock() + mock_call.assert_called_with("setLocked", True) + + +def test_unlock(region, mock_call_action): + reg = region() + mock_call = mock_call_action(reg) + reg.unlock() + mock_call.assert_called_with("setLocked", False) + + +def test_focus(region, mock_call_action): + reg = region() + mock_call = mock_call_action(reg) + reg.focus() + mock_call.assert_called_with("focusCenter") + + +@pytest.mark.parametrize("args,kwargs,expected_params", [ + (["/path/to/file"], {}, ["/path/to/file", CT.WORLD, FT.CRTF]), + (["/path/to/file", CT.PIXEL, FT.DS9_REG], {}, ["/path/to/file", CT.PIXEL, FT.DS9_REG]), + (["/path/to/file"], {"coordinate_type": CT.PIXEL}, ["/path/to/file", CT.PIXEL, FT.CRTF]), + (["/path/to/file"], {"file_type": FT.DS9_REG}, ["/path/to/file", CT.WORLD, FT.DS9_REG]), +]) +def test_export_to(region, mock_regionset_method, args, kwargs, expected_params): + reg = region() + mock_export = mock_regionset_method("export_to", None) + + reg.export_to(*args, **kwargs) + + mock_export.assert_called_with(*expected_params, [reg.region_id]) + + +def test_delete(region, mock_regionset_call_action): + reg = region() + reg.delete() + mock_regionset_call_action.assert_called_with("deleteRegion", Macro("", f"{reg.region_set._base_path}.regionMap[{reg.region_id}]")) From cdb4d03d56bbbf086acaecd4e854df43d66af2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Sat, 21 Oct 2023 03:09:02 +0200 Subject: [PATCH 21/50] More type-specific tests added; fixed minor bugs. --- carta/region.py | 6 +- tests/test_region.py | 256 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 237 insertions(+), 25 deletions(-) diff --git a/carta/region.py b/carta/region.py index 4e6cdc8..126f44c 100644 --- a/carta/region.py +++ b/carta/region.py @@ -829,7 +829,7 @@ def delete(self): class HasRotationMixin: - """This is a mixin class for regions which can be rotated.""" + """This is a mixin class for regions which can be rotated natively.""" # GET PROPERTIES @@ -1004,7 +1004,7 @@ def set_length(self, length): The new length, in pixels or angular size units. """ if isinstance(length, str): - length = self.length * AngularSize.from_string(length).arcsec / self.wcs_length + length = self.length * AngularSize.from_string(length).arcsec / AngularSize.from_string(self.wcs_length).arcsec rad = math.radians(self.rotation) @@ -1651,7 +1651,7 @@ def rotation(self): The rotation. """ ((sx, sy), (ex, ey)) = self.endpoints - rad = math.atan((ex - sx) / (sy - ey)) + rad = math.atan2(ex - sx, sy - ey) rotation = math.degrees(rad) if ey > sy: rotation += 180 diff --git a/tests/test_region.py b/tests/test_region.py index 3e42fef..e47e1c8 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -1,11 +1,12 @@ import pytest import inspect +import math from carta.session import Session from carta.image import Image import carta.region # For docstring inspection from carta.region import Region -from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT +from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF from carta.util import Point as Pt, Macro # FIXTURES @@ -51,6 +52,16 @@ def func(method_name, return_values): return func +@pytest.fixture +def mock_to_world(mock_image_method): + return mock_image_method("to_world_coordinate_points", lambda l: [(str(x), str(y)) for (x, y) in l]) + + +@pytest.fixture +def mock_to_angular(mock_image_method): + return mock_image_method("to_angular_size_points", lambda l: [(str(x), str(y)) for (x, y) in l]) + + # Regionset mocks @@ -74,16 +85,28 @@ def func(method_name, return_values): return func +@pytest.fixture +def mock_from_world(mock_regionset_method): + return mock_regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) + + +@pytest.fixture +def mock_from_angular(mock_regionset_method): + return mock_regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) + + # The region-specific mocks are all factories, so that they can be used to mock different region subclasses (specified by region type) @pytest.fixture -def region(image): +def region(mocker, image): """Return a factory for a new region object which uses the image fixture, specifying a class and/or ID. """ def func(region_type=None, region_id=0): clazz = Region if region_type is None else Region.region_class(region_type) - return clazz(image.regions, region_id) + mocker.patch(f"carta.region.{clazz.__name__}.region_type", new_callable=mocker.PropertyMock, return_value=region_type) + reg = clazz(image.regions, region_id) + return reg return func @@ -275,7 +298,7 @@ def test_regionset_add_region(mocker, image): ("add_ruler", [("10", "10"), ("20", "20")], {}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": ""}), ("add_ruler", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": "my region"}), ]) -def test_regionset_add_region_with_type(mocker, image, mock_regionset_method, region, func, args, kwargs, expected_args, expected_kwargs): +def test_regionset_add_region_with_type(mocker, image, mock_regionset_method, mock_from_world, mock_from_angular, region, func, args, kwargs, expected_args, expected_kwargs): mock_add_region = mock_regionset_method("add_region", None) if func == "add_text": @@ -283,9 +306,6 @@ def test_regionset_add_region_with_type(mocker, image, mock_regionset_method, re mock_add_region.return_value = text_annotation mock_set_text = mocker.patch.object(text_annotation, "set_text") - mock_regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) - mock_regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) - getattr(image.regions, func)(*args, **kwargs) mock_add_region.assert_called_with(*expected_args, **expected_kwargs) @@ -305,8 +325,8 @@ def test_regionset_clear(mocker, image, mock_regionset_method, mock_method, regi m.assert_called_with() -def test_region_type(region, mock_get_value): - reg = region() +def test_region_type(image, mock_get_value): + reg = Region(image.regions, 0) # Bypass the default to test the real region_type reg_mock_get_value = mock_get_value(reg, 3) region_type = reg.region_type @@ -325,14 +345,13 @@ def test_center(region, mock_get_value): assert center == (20, 30) -def test_wcs_center(region, mock_property, mock_image_method): +def test_wcs_center(region, mock_property, mock_to_world): reg = region() mock_property(reg, "center", (20, 30)) - mock_to_wcs = mock_image_method("to_world_coordinate_points", lambda l: [(str(x), str(y)) for (x, y) in l]) wcs_center = reg.wcs_center - mock_to_wcs.assert_called_with([(20, 30)]) + mock_to_world.assert_called_with([(20, 30)]) assert wcs_center == ("20", "30") @@ -357,13 +376,12 @@ def test_size(region, mock_get_value, region_type): @pytest.mark.parametrize("region_type", [t for t in RT]) -def test_wcs_size(region, mock_get_value, mock_property, mock_image_method, region_type): +def test_wcs_size(region, mock_get_value, mock_property, mock_to_angular, region_type): reg = region(region_type) if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): # Bypasses wcsSize to call own (overridden) size and converts to angular units mock_property(reg, "size", (20, 30)) - mock_to_ang = mock_image_method("to_angular_size_points", lambda l: [(str(x), str(y)) for (x, y) in l]) elif region_type in (RT.POINT, RT.ANNPOINT): # Simulate undefined size reg_mock_get_value = mock_get_value(reg, {"x": None, "y": None}) @@ -373,7 +391,7 @@ def test_wcs_size(region, mock_get_value, mock_property, mock_image_method, regi size = reg.wcs_size if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): - mock_to_ang.assert_called_with([(20, 30)]) + mock_to_angular.assert_called_with([(20, 30)]) assert size == ("20", "30") elif region_type in (RT.POINT, RT.ANNPOINT): reg_mock_get_value.assert_called_with("wcsSize") @@ -403,17 +421,14 @@ def test_simple_properties(region, mock_get_value, method_name, value_name): mock_value_getter.assert_called_with(value_name) assert value == "dummy" -# Overridden behaviour for Ruler - @pytest.mark.parametrize("region_type", [t for t in RT]) @pytest.mark.parametrize("value,expected_value", [ ((20, 30), Pt(20, 30)), (("20", "30"), Pt(20, 30)), ]) -def test_set_center(region, mock_regionset_method, mock_call_action, mock_method, mock_property, region_type, value, expected_value): +def test_set_center(region, mock_from_world, mock_call_action, mock_method, mock_property, region_type, value, expected_value): reg = region(region_type) - mock_regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) if region_type == RT.ANNRULER: mock_property(reg, "size", (-10, -10)) @@ -435,7 +450,7 @@ def test_set_center(region, mock_regionset_method, mock_call_action, mock_method ((20, 30), Pt(20, 30)), (("20", "30"), Pt(20, 30)), ]) -def test_set_size(region, mock_regionset_method, mock_call_action, mock_method, mock_property, region_type, value, expected_value): +def test_set_size(region, mock_from_angular, mock_call_action, mock_method, mock_property, region_type, value, expected_value): reg = region(region_type) if region_type == RT.ANNCOMPASS: @@ -444,8 +459,6 @@ def test_set_size(region, mock_regionset_method, mock_call_action, mock_method, assert "Compass annotation width and height cannot be set individually" in str(e.value) return - mock_regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) - if region_type == RT.ANNRULER: mock_set_points = mock_method(reg, "set_control_points", None) mock_property(reg, "center", (10, 10)) @@ -538,3 +551,202 @@ def test_delete(region, mock_regionset_call_action): reg = region() reg.delete() mock_regionset_call_action.assert_called_with("deleteRegion", Macro("", f"{reg.region_set._base_path}.regionMap[{reg.region_id}]")) + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) +def test_rotation(region, mock_get_value, mock_property, region_type): + reg = region(region_type) + + if region_type == RT.ANNRULER: + mock_property(reg, "endpoints", [(90, 110), (110, 90)]) + else: + mock_rotation = mock_get_value(reg, "dummy") + + value = reg.rotation + + if region_type == RT.ANNRULER: + assert value == 45 + else: + mock_rotation.assert_called_with("rotation") + assert value == "dummy" + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) +def test_set_rotation(region, mock_call_action, mock_method, mock_property, region_type): + reg = region(region_type) + + if region_type == RT.ANNRULER: + mock_property(reg, "center", (100, 100)) + mock_property(reg, "size", (20, 20)) + mock_set_points = mock_method(reg, "set_control_points", None) + else: + mock_call = mock_call_action(reg) + + reg.set_rotation(45) + + if region_type == RT.ANNRULER: + mock_set_points.assert_called_with([(90, 110), (110, 90)]) + else: + mock_call.assert_called_with("setRotation", 45) + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +def test_vertices(region, mock_property, region_type): + reg = region(region_type) + mock_property(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) + vertices = reg.vertices + assert vertices == [(10, 10), (20, 30), (30, 20)] + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +def test_wcs_vertices(region, mock_property, mock_to_world, region_type): + reg = region(region_type) + mock_property(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) + vertices = reg.wcs_vertices + assert vertices == [("10", "10"), ("20", "30"), ("30", "20")] + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +@pytest.mark.parametrize("vertex", [(30, 40), ("30", "40")]) +def test_set_vertex(region, mock_method, mock_from_world, region_type, vertex): + reg = region(region_type) + mock_set_control_point = mock_method(reg, "set_control_point", None) + reg.set_vertex(1, vertex) + mock_set_control_point.assert_called_with(1, (30, 40)) + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +@pytest.mark.parametrize("vertices", [ + [(10, 10), (20, 30), (30, 20)], + [("10", "10"), ("20", "30"), ("30", "20")], +]) +def test_set_vertices(region, mock_method, mock_from_world, region_type, vertices): + reg = region(region_type) + mock_set_control_points = mock_method(reg, "set_control_points", None) + reg.set_vertices(vertices) + mock_set_control_points.assert_called_with([(10, 10), (20, 30), (30, 20)]) + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_endpoints(region, mock_property, region_type): + reg = region(region_type) + mock_property(reg, "control_points", [(10, 10), (20, 30)]) + endpoints = reg.endpoints + assert endpoints == [(10, 10), (20, 30)] + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_wcs_endpoints(region, mock_property, mock_to_world, region_type): + reg = region(region_type) + mock_property(reg, "control_points", [(10, 10), (20, 30)]) + endpoints = reg.wcs_endpoints + assert endpoints == [("10", "10"), ("20", "30")] + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_length(region, mock_property, region_type): + reg = region(region_type) + mock_property(reg, "size", (30, 40)) + length = reg.length + assert length == 50 + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_wcs_length(region, mock_property, region_type): + reg = region(region_type) + mock_property(reg, "wcs_size", ("30", "40")) + length = reg.wcs_length + assert length == "50\"" + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([(10, 10), (20, 30)], {}, [(0, (10, 10)), (1, (20, 30))]), + ([("10", "10"), ("20", "30")], {}, [(0, (10, 10)), (1, (20, 30))]), + ([(10, 10), ("20", "30")], {}, [(0, (10, 10)), (1, (20, 30))]), + ([], {"start": (10, 10)}, [(0, (10, 10))]), + ([], {"end": (20, 30)}, [(1, (20, 30))]), +]) +def test_set_endpoints(mocker, region, mock_method, mock_from_world, region_type, args, kwargs, expected_calls): + reg = region(region_type) + mock_set_control_point = mock_method(reg, "set_control_point", None) + reg.set_endpoints(*args, **kwargs) + mock_set_control_point.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +@pytest.mark.parametrize("length", [math.sqrt(800), str(math.sqrt(800))]) +def test_set_length(mocker, region, mock_property, region_type, length): + reg = region(region_type) + + mock_property(reg, "length", 100) + mock_property(reg, "wcs_length", "100") + mock_property(reg, "rotation", 45) + mock_region_set_size = mocker.patch("carta.region.Region.set_size", autospec=True) + + reg.set_length(length) + + mock_region_set_size.assert_called() + r, (s1, s2) = mock_region_set_size.call_args.args + assert r == reg + assert math.isclose(s1, 20) + assert math.isclose(s2, -20) + + +@pytest.mark.parametrize("region_type", {RT.ANNTEXT, RT.ANNCOMPASS, RT.ANNRULER}) +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("font_size", "fontSize", 20, 20), + ("font_style", "fontStyle", "Bold", AFS.BOLD), + ("font", "font", "Courier", AF.COURIER), +]) +def test_font_properties(region, mock_get_value, region_type, method_name, value_name, mocked_value, expected_value): + reg = region(region_type) + mock_value_getter = mock_get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +@pytest.mark.parametrize("region_type", {RT.ANNTEXT, RT.ANNCOMPASS, RT.ANNRULER}) +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([AF.COURIER, 20, AFS.BOLD], {}, [("setFont", AF.COURIER), ("setFontSize", 20), ("setFontStyle", AFS.BOLD)]), + ([], {"font": AF.COURIER, "font_size": 20, "font_style": AFS.BOLD}, [("setFont", AF.COURIER), ("setFontSize", 20), ("setFontStyle", AFS.BOLD)]), + ([AF.COURIER], {"font_style": AFS.BOLD}, [("setFont", AF.COURIER), ("setFontStyle", AFS.BOLD)]), + ([], {"font_size": 20}, [("setFontSize", 20)]), +]) +def test_set_font(mocker, region, mock_call_action, region_type, args, kwargs, expected_calls): + reg = region(region_type) + mock_action_caller = mock_call_action(reg) + reg.set_font(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("region_type", {RT.ANNVECTOR, RT.ANNCOMPASS}) +@pytest.mark.parametrize("method_name,value_name", [ + ("pointer_width", "pointerWidth"), + ("pointer_length", "pointerLength"), +]) +def test_pointer_properties(region, mock_get_value, region_type, method_name, value_name): + reg = region(region_type) + mock_value_getter = mock_get_value(reg, "dummy") + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == "dummy" + + +@pytest.mark.parametrize("region_type", {RT.ANNVECTOR, RT.ANNCOMPASS}) +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([2, 20], {}, [("setPointerWidth", 2), ("setPointerLength", 20)]), + ([], {"pointer_length": 20}, [("setPointerLength", 20)]), +]) +def test_set_pointer_style(mocker, region, mock_call_action, region_type, args, kwargs, expected_calls): + reg = region(region_type) + mock_action_caller = mock_call_action(reg) + reg.set_pointer_style(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +# TODO separate length tests for compass annotation From 4a4aa99ac293cacb39426198c326df54d74eef14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Sun, 22 Oct 2023 02:03:08 +0200 Subject: [PATCH 22/50] added more specific tests; reordered inheritance to allow correct use of super --- carta/region.py | 27 ++++++++------ tests/test_region.py | 89 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/carta/region.py b/carta/region.py index 126f44c..9cb3f17 100644 --- a/carta/region.py +++ b/carta/region.py @@ -1008,7 +1008,7 @@ def set_length(self, length): rad = math.radians(self.rotation) - Region.set_size(self, (length * math.sin(rad), -1 * length * math.cos(rad))) + super().set_size((length * math.sin(rad), -1 * length * math.cos(rad))) class HasFontMixin: @@ -1123,7 +1123,7 @@ def set_pointer_style(self, pointer_width=None, pointer_length=None): self.call_action("setPointerLength", pointer_length) -class LineRegion(Region, HasEndpointsMixin, HasRotationMixin): +class LineRegion(HasEndpointsMixin, HasRotationMixin, Region): """A line region or annotation.""" REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) @@ -1140,20 +1140,20 @@ def set_size(self, size): """ [size] = self.region_set._from_angular_sizes([size]) sx, sy = size - Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size + super().set_size((-sx, -sy)) # negated for consistency with returned size -class PolylineRegion(Region, HasVerticesMixin): +class PolylineRegion(HasVerticesMixin, Region): """A polyline region or annotation.""" REGION_TYPES = (RegionType.POLYLINE, RegionType.ANNPOLYLINE) -class PolygonRegion(Region, HasVerticesMixin): +class PolygonRegion(HasVerticesMixin, Region): """A polygonal region or annotation.""" REGION_TYPES = (RegionType.POLYGON, RegionType.ANNPOLYGON) -class RectangularRegion(Region, HasRotationMixin): +class RectangularRegion(HasRotationMixin, Region): """A rectangular region or annotation.""" REGION_TYPES = (RegionType.RECTANGLE, RegionType.ANNRECTANGLE) @@ -1203,6 +1203,9 @@ def set_corners(self, bottom_left=None, top_right=None): top_right : {1} The new top-right corner position, in image or world coordinates. """ + if bottom_left is None and top_right is None: + return + if bottom_left is None or top_right is None: current_bottom_left, current_top_right = self.corners @@ -1225,7 +1228,7 @@ def set_corners(self, bottom_left=None, top_right=None): self.set_control_points([center, size.as_tuple()]) -class EllipticalRegion(Region, HasRotationMixin): +class EllipticalRegion(HasRotationMixin, Region): """An elliptical region or annotation.""" REGION_TYPES = (RegionType.ELLIPSE, RegionType.ANNELLIPSE) @@ -1376,7 +1379,7 @@ def set_point_style(self, point_shape, point_width): self.call_action("setPointWidth", point_width) -class TextAnnotation(Region, HasFontMixin, HasRotationMixin): +class TextAnnotation(HasFontMixin, HasRotationMixin, Region): """A text annotation.""" REGION_TYPE = RegionType.ANNTEXT @@ -1429,7 +1432,7 @@ def set_text_position(self, text_position): self.call_action("setPosition", text_position) -class VectorAnnotation(Region, HasPointerMixin, HasEndpointsMixin, HasRotationMixin): +class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, Region): """A vector annotation.""" REGION_TYPE = RegionType.ANNVECTOR @@ -1446,10 +1449,10 @@ def set_size(self, size): """ [size] = self.region_set._from_angular_sizes([size]) sx, sy = size - Region.set_size(self, (-sx, -sy)) # negated for consistency with returned size + super().set_size((-sx, -sy)) # negated for consistency with returned size -class CompassAnnotation(Region, HasFontMixin, HasPointerMixin): +class CompassAnnotation(HasFontMixin, HasPointerMixin, Region): """A compass annotation.""" REGION_TYPE = RegionType.ANNCOMPASS @@ -1600,7 +1603,7 @@ def set_arrowhead_visible(self, north=None, east=None): self.call_action("setEastArrowhead", east) -class RulerAnnotation(Region, HasFontMixin, HasEndpointsMixin): +class RulerAnnotation(HasFontMixin, HasEndpointsMixin, Region): """A ruler annotation.""" REGION_TYPE = RegionType.ANNRULER diff --git a/tests/test_region.py b/tests/test_region.py index e47e1c8..881e02c 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -335,8 +335,9 @@ def test_region_type(image, mock_get_value): assert region_type == RT.RECTANGLE -def test_center(region, mock_get_value): - reg = region() +@pytest.mark.parametrize("region_type", [t for t in RT]) +def test_center(region, mock_get_value, region_type): + reg = region(region_type) reg_mock_get_value = mock_get_value(reg, {"x": 20, "y": 30}) center = reg.center @@ -345,8 +346,9 @@ def test_center(region, mock_get_value): assert center == (20, 30) -def test_wcs_center(region, mock_property, mock_to_world): - reg = region() +@pytest.mark.parametrize("region_type", [t for t in RT]) +def test_wcs_center(region, mock_property, mock_to_world, region_type): + reg = region(region_type) mock_property(reg, "center", (20, 30)) wcs_center = reg.wcs_center @@ -683,13 +685,12 @@ def test_set_length(mocker, region, mock_property, region_type, length): mock_property(reg, "length", 100) mock_property(reg, "wcs_length", "100") mock_property(reg, "rotation", 45) - mock_region_set_size = mocker.patch("carta.region.Region.set_size", autospec=True) + mock_region_set_size = mocker.patch.object(Region, "set_size") reg.set_length(length) mock_region_set_size.assert_called() - r, (s1, s2) = mock_region_set_size.call_args.args - assert r == reg + (s1, s2), = mock_region_set_size.call_args.args assert math.isclose(s1, 20) assert math.isclose(s2, -20) @@ -749,4 +750,78 @@ def test_set_pointer_style(mocker, region, mock_call_action, region_type, args, mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +def test_corners(region, mock_property, region_type): + reg = region(region_type) + mock_property(reg, "center", (100, 200)) + mock_property(reg, "size", (30, 40)) + + bottom_left, top_right = reg.corners + + assert bottom_left == (85, 180) + assert top_right == (115, 220) + + +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +def test_wcs_corners(region, mock_property, mock_to_world, region_type): + reg = region(region_type) + mock_property(reg, "corners", [(85, 180), (115, 220)]) + + bottom_left, top_right = reg.wcs_corners + + assert bottom_left == ("85", "180") + assert top_right == ("115", "220") + + +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +@pytest.mark.parametrize("args,kwargs,expected_args", [ + ([], {}, None), + ([(75, 170), (135, 240)], {}, [(105.0, 205.0), (60, 70)]), + ([(75, 170)], {}, [(95.0, 195.0), (40, 50)]), + ([], {"top_right": (135, 240)}, [(110.0, 210.0), (50, 60)]), +]) +def test_set_corners(region, mock_method, mock_property, mock_from_world, region_type, args, kwargs, expected_args): + reg = region(region_type) + mock_property(reg, "corners", [(85, 180), (115, 220)]) + mock_set_control_points = mock_method(reg, "set_control_points", None) + + reg.set_corners(*args, **kwargs) + + if expected_args is None: + mock_set_control_points.assert_not_called() + else: + mock_set_control_points.assert_called_with(expected_args) + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +def test_semi_axes(mocker, region, region_type): + reg = region(region_type) + mocker.patch("carta.region.Region.size", new_callable=mocker.PropertyMock, return_value=(20, 30)) + + semi_axes = reg.semi_axes + + assert semi_axes == (20, 30) + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +def test_wcs_semi_axes(mocker, region, region_type): + reg = region(region_type) + mocker.patch("carta.region.Region.wcs_size", new_callable=mocker.PropertyMock, return_value=("20", "30")) + + semi_axes = reg.wcs_semi_axes + + assert semi_axes == ("20", "30") + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +@pytest.mark.parametrize("semi_axes", [(20, 30), ("20", "30")]) +def test_set_semi_axes(mocker, region, mock_from_angular, region_type, semi_axes): + reg = region(region_type) + mock_region_set_size = mocker.patch.object(Region, "set_size") + + reg.set_semi_axes(semi_axes) + + mock_region_set_size.assert_called_with((20, 30)) + + # TODO separate length tests for compass annotation From b454ef3ddf1566ffc4c58b1852b6ef69e0a29eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 23 Oct 2023 11:46:27 +0200 Subject: [PATCH 23/50] Added tests for point annotation and fixed bug --- carta/region.py | 2 +- tests/test_region.py | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/carta/region.py b/carta/region.py index 9cb3f17..d3c5a6e 100644 --- a/carta/region.py +++ b/carta/region.py @@ -1361,7 +1361,7 @@ def point_width(self): # SET PROPERTIES @validate(*all_optional(Constant(PointShape), Number())) - def set_point_style(self, point_shape, point_width): + def set_point_style(self, point_shape=None, point_width=None): """Set the point style. All parameters are optional. Omitted properties will be left unmodified. diff --git a/tests/test_region.py b/tests/test_region.py index 881e02c..7cff098 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -6,7 +6,7 @@ from carta.image import Image import carta.region # For docstring inspection from carta.region import Region -from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF +from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS from carta.util import Point as Pt, Macro # FIXTURES @@ -824,4 +824,29 @@ def test_set_semi_axes(mocker, region, mock_from_angular, region_type, semi_axes mock_region_set_size.assert_called_with((20, 30)) +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("point_shape", "pointShape", 2, PS.CIRCLE), + ("point_width", "pointWidth", 5, 5), +]) +def test_point_properties(region, mock_get_value, method_name, value_name, mocked_value, expected_value): + reg = region(RT.ANNPOINT) + mock_value_getter = mock_get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([PS.CIRCLE, 5], {}, [("setPointShape", PS.CIRCLE), ("setPointWidth", 5)]), + ([], {"point_shape": PS.CIRCLE}, [("setPointShape", PS.CIRCLE)]), + ([], {"point_width": 5}, [("setPointWidth", 5)]), +]) +def test_set_point_style(mocker, region, mock_call_action, args, kwargs, expected_calls): + reg = region(RT.ANNPOINT) + mock_action_caller = mock_call_action(reg) + reg.set_point_style(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + # TODO separate length tests for compass annotation From 59347f10a55feaed6314cd31fca413000bb6a868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 23 Oct 2023 14:51:00 +0200 Subject: [PATCH 24/50] Finished specific region class tests; refactored set_size code --- carta/region.py | 71 ++++++++++----------- tests/test_region.py | 149 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 173 insertions(+), 47 deletions(-) diff --git a/carta/region.py b/carta/region.py index d3c5a6e..ff551ee 100644 --- a/carta/region.py +++ b/carta/region.py @@ -972,6 +972,21 @@ def wcs_length(self): # SET PROPERTIES + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [size] = self.region_set._from_angular_sizes([size]) + sx, sy = size + super().set_size((-sx, -sy)) # negated for consistency with returned size + @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) def set_endpoints(self, start=None, end=None): """Update the endpoints. @@ -1127,21 +1142,6 @@ class LineRegion(HasEndpointsMixin, HasRotationMixin, Region): """A line region or annotation.""" REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) - @validate(Point.SizePoint()) - def set_size(self, size): - """Set the size. - - Both pixel and angular sizes are accepted, but both values must match. - - Parameters - ---------- - size : {0} - The new width and height, in that order. - """ - [size] = self.region_set._from_angular_sizes([size]) - sx, sy = size - super().set_size((-sx, -sy)) # negated for consistency with returned size - class PolylineRegion(HasVerticesMixin, Region): """A polyline region or annotation.""" @@ -1436,21 +1436,6 @@ class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, Reg """A vector annotation.""" REGION_TYPE = RegionType.ANNVECTOR - @validate(Point.SizePoint()) - def set_size(self, size): - """Set the size. - - Both pixel and angular sizes are accepted, but both values must match. - - Parameters - ---------- - size : {0} - The new width and height, in that order. - """ - [size] = self.region_set._from_angular_sizes([size]) - sx, sy = size - super().set_size((-sx, -sy)) # negated for consistency with returned size - class CompassAnnotation(HasFontMixin, HasPointerMixin, Region): """A compass annotation.""" @@ -1472,7 +1457,7 @@ def labels(self): return self.get_value("northLabel"), self.get_value("eastLabel") @property - def length(self): + def point_length(self): """The length of the compass points, in pixels. Returns @@ -1510,17 +1495,23 @@ def arrowheads_visible(self): # SET PROPERTIES + @validate(Point.SizePoint()) def set_size(self, size): """Set the size. - The width and height of this annotation cannot be set individually. :obj:`carta.region.CompassAnnotation.set_length` should be used instead. + Both pixel and angular sizes are accepted, but both values must match. - Raises - ------ - NotImplementedError - If this function is called. + The width and height of this annotation cannot be set independently. If two different values are provided, the smaller value will be used (after conversion to pixel units). + + This function is provided for compatibility. Also see :obj:`carta.region.CompassAnnotation.set_point_length` for a more convenient way to resize this annotation. + + Parameters + ---------- + size : {0} + The new size. """ - raise NotImplementedError("Compass annotation width and height cannot be set individually. Please use `set_length` to resize this annotation.") + [size] = self.region_set._from_angular_sizes([size]) + self.call_action("setLength", min(*size)) @validate(*all_optional(String(), String())) def set_label(self, north_label=None, east_label=None): @@ -1541,7 +1532,7 @@ def set_label(self, north_label=None, east_label=None): self.call_action("setLabel", east_label, False) @validate(Size(), NoneOr(Constant(SpatialAxis))) - def set_length(self, length, spatial_axis=None): + def set_point_length(self, length, spatial_axis=None): """Set the length of the compass points. If the length is provided in angular size units, a spatial axis must also be provided in order for the angular size to be converted to pixels. @@ -1578,9 +1569,11 @@ def set_label_offset(self, north_offset=None, east_offset=None): The east label offset, in pixels. """ if north_offset is not None: + north_offset = Pt(*north_offset) self.call_action("setNorthTextOffset", north_offset.x, True) self.call_action("setNorthTextOffset", north_offset.y, False) if east_offset is not None: + east_offset = Pt(*east_offset) self.call_action("setEastTextOffset", east_offset.x, True) self.call_action("setEastTextOffset", east_offset.y, False) @@ -1642,7 +1635,7 @@ def text_offset(self): number The Y offset of the text, in pixels. """ - return Pt(*self.get_value("textOffset")).as_tuple() + return Pt(**self.get_value("textOffset")).as_tuple() @property def rotation(self): diff --git a/tests/test_region.py b/tests/test_region.py index 7cff098..aef9088 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -6,7 +6,7 @@ from carta.image import Image import carta.region # For docstring inspection from carta.region import Region -from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS +from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS, TextPosition as TP, SpatialAxis as SA from carta.util import Point as Pt, Macro # FIXTURES @@ -455,12 +455,6 @@ def test_set_center(region, mock_from_world, mock_call_action, mock_method, mock def test_set_size(region, mock_from_angular, mock_call_action, mock_method, mock_property, region_type, value, expected_value): reg = region(region_type) - if region_type == RT.ANNCOMPASS: - with pytest.raises(NotImplementedError) as e: - reg.set_size(value) - assert "Compass annotation width and height cannot be set individually" in str(e.value) - return - if region_type == RT.ANNRULER: mock_set_points = mock_method(reg, "set_control_points", None) mock_property(reg, "center", (10, 10)) @@ -471,6 +465,8 @@ def test_set_size(region, mock_from_angular, mock_call_action, mock_method, mock if region_type == RT.ANNRULER: mock_set_points.assert_called_with([(20.0, 25.0), (0.0, -5.0)]) + elif region_type == RT.ANNCOMPASS: + mock_call.assert_called_with("setLength", min(expected_value.x, expected_value.y)) elif region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR}: mock_call.assert_called_with("setSize", Pt(- expected_value.x, - expected_value.y)) elif region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: @@ -849,4 +845,141 @@ def test_set_point_style(mocker, region, mock_call_action, args, kwargs, expecte mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) -# TODO separate length tests for compass annotation +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("text", "text", "my text", "my text"), + ("position", "position", 3, TP.LOWER_LEFT), +]) +def test_text_properties(region, mock_get_value, method_name, value_name, mocked_value, expected_value): + reg = region(RT.ANNTEXT) + mock_value_getter = mock_get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +def test_set_text(region, mock_call_action): + reg = region(RT.ANNTEXT) + mock_action_caller = mock_call_action(reg) + reg.set_text("my text") + mock_action_caller.assert_called_with("setText", "my text") + + +def test_set_text_position(region, mock_call_action): + reg = region(RT.ANNTEXT) + mock_action_caller = mock_call_action(reg) + reg.set_text_position(TP.LOWER_LEFT) + mock_action_caller.assert_called_with("setPosition", TP.LOWER_LEFT) + + +@pytest.mark.parametrize("method_name,value_names,mocked_values,expected_value", [ + ("labels", ["northLabel", "eastLabel"], ["N", "E"], ("N", "E")), + ("point_length", ["length"], [100], 100), + ("label_offsets", ["northTextOffset", "eastTextOffset"], [{"x": 1, "y": 2}, {"x": 3, "y": 4}], ((1, 2), (3, 4))), + ("arrowheads_visible", ["northArrowhead", "eastArrowhead"], [True, False], (True, False)), +]) +def test_compass_properties(region, mocker, method_name, value_names, mocked_values, expected_value): + reg = region(RT.ANNCOMPASS) + mock_value_getter = mocker.patch.object(reg, "get_value", side_effect=mocked_values) + value = getattr(reg, method_name) + mock_value_getter.assert_has_calls([mocker.call(name) for name in value_names]) + assert value == expected_value + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + (["N", "E"], {}, [("setLabel", "N", True), ("setLabel", "E", False),]), + (["N"], {}, [("setLabel", "N", True)]), + ([], {"east_label": "E"}, [("setLabel", "E", False)]), +]) +def test_set_label(mocker, region, mock_call_action, args, kwargs, expected_calls): + reg = region(RT.ANNCOMPASS) + mock_action_caller = mock_call_action(reg) + reg.set_label(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls,error_contains", [ + ([100], {}, [("setLength", 100)], None), + (["100", SA.X], {}, [("setLength", 100)], None), + (["100"], {"spatial_axis": SA.X}, [("setLength", 100)], None), + (["100"], {}, [], "Please specify a spatial axis"), +]) +def test_set_point_length(mocker, region, mock_call_action, mock_image_method, args, kwargs, expected_calls, error_contains): + reg = region(RT.ANNCOMPASS) + mock_action_caller = mock_call_action(reg) + mock_image_method("from_angular_size", [100]) + + if error_contains is None: + reg.set_point_length(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + else: + with pytest.raises(ValueError) as e: + reg.set_point_length(*args, **kwargs) + assert error_contains in str(e.value) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([(1, 2), (3, 4)], {}, [("setNorthTextOffset", 1, True), ("setNorthTextOffset", 2, False), ("setEastTextOffset", 3, True), ("setEastTextOffset", 4, False)]), + ([(1, 2)], {}, [("setNorthTextOffset", 1, True), ("setNorthTextOffset", 2, False)]), + ([], {"east_offset": (3, 4)}, [("setEastTextOffset", 3, True), ("setEastTextOffset", 4, False)]), +]) +def test_set_label_offset(mocker, region, mock_call_action, args, kwargs, expected_calls): + reg = region(RT.ANNCOMPASS) + mock_action_caller = mock_call_action(reg) + reg.set_label_offset(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([True, False], {}, [("setNorthArrowhead", True), ("setEastArrowhead", False)]), + ([True], {}, [("setNorthArrowhead", True)]), + ([], {"east": False}, [("setEastArrowhead", False)]), +]) +def test_set_arrowhead_visible(mocker, region, mock_call_action, args, kwargs, expected_calls): + reg = region(RT.ANNCOMPASS) + mock_action_caller = mock_call_action(reg) + reg.set_arrowhead_visible(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("auxiliary_lines_visible", "auxiliaryLineVisible", True, True), + ("auxiliary_lines_dash_length", "auxiliaryLineDashLength", 5, 5), + ("text_offset", "textOffset", {"x": 1, "y": 2}, (1, 2)), +]) +def test_ruler_properties(region, mock_get_value, method_name, value_name, mocked_value, expected_value): + reg = region(RT.ANNRULER) + mock_value_getter = mock_get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([True, 5], {}, [("setAuxiliaryLineVisible", True), ("setAuxiliaryLineDashLength", 5)]), + ([True], {}, [("setAuxiliaryLineVisible", True)]), + ([], {"dash_length": 5}, [("setAuxiliaryLineDashLength", 5)]), +]) +def test_set_auxiliary_lines_style(mocker, region, mock_call_action, args, kwargs, expected_calls): + reg = region(RT.ANNRULER) + mock_action_caller = mock_call_action(reg) + reg.set_auxiliary_lines_style(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([1, 2], {}, [("setTextOffset", 1, True), ("setTextOffset", 2, False)]), + ([1], {}, [("setTextOffset", 1, True)]), + ([], {"offset_y": 2}, [("setTextOffset", 2, False)]), +]) +def test_set_text_offset(mocker, region, mock_call_action, args, kwargs, expected_calls): + reg = region(RT.ANNRULER) + mock_action_caller = mock_call_action(reg) + reg.set_text_offset(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + +# TODO n.b. move default endpoint length method into mixin From babe063fb7e15928ec46d29a24e6f129a1d9816b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 23 Oct 2023 15:49:05 +0200 Subject: [PATCH 25/50] Added tests for conversion functions in image class; fixed bug --- carta/image.py | 3 +- carta/util.py | 2 +- tests/test_image.py | 69 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/carta/image.py b/carta/image.py index 3abfef8..7e6406b 100644 --- a/carta/image.py +++ b/carta/image.py @@ -774,7 +774,8 @@ def to_angular_size_points(self, points): """ converted_points = [] for p in points: - converted_points.append(self.call_action("getWcsSizeInArcsec", Pt(*p))) + converted = self.call_action("getWcsSizeInArcsec", Pt(*p)) + converted_points.append(Pt(**converted).as_tuple()) return converted_points # CLOSE diff --git a/carta/util.py b/carta/util.py index afeb201..b6ff0ac 100644 --- a/carta/util.py +++ b/carta/util.py @@ -231,7 +231,7 @@ def __init__(self, x, y): self.y = y def __eq__(self, other): - return self.x == other.x and self.y == other.y + return hasattr(other, "x") and hasattr(other, "y") and self.x == other.x and self.y == other.y @classmethod def is_pixel(cls, x, y): diff --git a/tests/test_image.py b/tests/test_image.py index 1f04c4d..9e15453 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -3,7 +3,7 @@ from carta.session import Session from carta.image import Image -from carta.util import CartaValidationFailed +from carta.util import CartaValidationFailed, Point as Pt from carta.constants import NumberFormat as NF, SpatialAxis as SA # FIXTURES @@ -245,3 +245,70 @@ def test_zoom_to_size_invalid(image, mock_property, axis, val, wcs, error_contai with pytest.raises(Exception) as e: image.zoom_to_size(val, axis) assert error_contains in str(e.value) + + +def test_from_world_coordinate_points(image, mock_call_action): + mock_call_action.return_value = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}] + points = image.from_world_coordinate_points([("1", "2"), ("3", "4"), ("5", "6")]) + mock_call_action.assert_called_with("getImagePosFromWCS", [Pt("1", "2"), Pt("3", "4"), Pt("5", "6")]) + assert points == [(1, 2), (3, 4), (5, 6)] + + +def test_from_world_coordinate_points_invalid(image): + with pytest.raises(CartaValidationFailed) as e: + image.from_world_coordinate_points([(1, 2), (3, 4), (5, 6)]) + assert "not a pair of coordinate strings" in str(e.value) + + +def test_to_world_coordinate_points(image, mock_call_action): + mock_call_action.return_value = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}, {"x": "5", "y": "6"}] + points = image.to_world_coordinate_points([(1, 2), (3, 4), (5, 6)]) + mock_call_action.assert_called_with("getWCSFromImagePos", [Pt(1, 2), Pt(3, 4), Pt(5, 6)]) + assert points == [("1", "2"), ("3", "4"), ("5", "6")] + + +def test_to_world_coordinate_points_invalid(image): + with pytest.raises(CartaValidationFailed) as e: + image.to_world_coordinate_points([("1", "2"), ("3", "4"), ("5", "6")]) + assert "not a pair of numbers" in str(e.value) + + +@pytest.mark.parametrize("size,axis,expected_call", [ + ("100\"", SA.X, ("getImageXValueFromArcsec", 100)), + ("100\"", SA.Y, ("getImageYValueFromArcsec", 100)), +]) +def test_from_angular_size(image, mock_call_action, size, axis, expected_call): + image.from_angular_size(size, axis) + mock_call_action.assert_called_with(*expected_call) + + +@pytest.mark.parametrize("size,error_contains", [ + (100, "a string was expected"), + ("100abc", "not an angular size"), +]) +def test_from_angular_size_invalid(image, size, error_contains): + with pytest.raises(CartaValidationFailed) as e: + image.from_angular_size(size, SA.X) + assert error_contains in str(e.value) + + +def test_from_angular_size_points(mocker, image, mock_method): + mock_from_angular_size = mock_method("from_angular_size", [1, 2, 3, 4]) + points = image.from_angular_size_points([("1", "2"), ("3", "4")]) + mock_from_angular_size.assert_has_calls([ + mocker.call("1", SA.X), + mocker.call("2", SA.Y), + mocker.call("3", SA.X), + mocker.call("4", SA.Y), + ]) + assert points == [(1, 2), (3, 4)] + + +def test_to_angular_size_points(mocker, image, mock_call_action): + mock_call_action.side_effect = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}] + points = image.to_angular_size_points([(1, 2), (3, 4)]) + mock_call_action.assert_has_calls([ + mocker.call("getWcsSizeInArcsec", Pt(1, 2)), + mocker.call("getWcsSizeInArcsec", Pt(3, 4)), + ]) + assert points == [("1", "2"), ("3", "4")] From dae85e3a0e9c5d746acbeeca9c971b4b79c688da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 23 Oct 2023 16:00:07 +0200 Subject: [PATCH 26/50] Added tests for Point utility class --- tests/test_util.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_util.py diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..5bb8b3f --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,36 @@ +from carta.util import Point as Pt + + +def test_point_equality(): + assert Pt(1, 2) == Pt(1, 2) + assert Pt("1", "2") == Pt("1", "2") + assert Pt(1, 2) != Pt("1", "2") + assert Pt(1, 2) != (1, 2) + + +def test_point_is_pixel(): + assert Pt.is_pixel(1, 2) + assert not Pt.is_pixel("1", 2) + assert not Pt.is_pixel("1", "2") + + +def test_point_is_wcs(): + assert Pt.is_wcs_coordinate("123", "123") + assert Pt.is_wcs_coordinate("12:34:56", "12:34:56") + assert not Pt.is_wcs_coordinate(1, 2) + + +def test_point_is_angular(): + assert Pt.is_angular_size("123", "123") + assert Pt.is_angular_size("123'", "123'") + assert not Pt.is_angular_size(1, 2) + + +def test_point_json(): + assert Pt(1, 2).json() == {"x": 1, "y": 2} + assert Pt("1", "2").json() == {"x": "1", "y": "2"} + + +def test_point_tuple(): + assert Pt(1, 2).as_tuple() == (1, 2) + assert Pt("1", "2").as_tuple() == ("1", "2") From ed271f318fe6465cb3a1cec0e8742624ab688c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 23 Oct 2023 16:15:44 +0200 Subject: [PATCH 27/50] Added tests for Point validator --- tests/test_validation.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index 3cbe650..5bb43e0 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,6 +1,6 @@ import pytest -from carta.validation import Size, Coordinate +from carta.validation import Size, Coordinate, Point @pytest.mark.parametrize('val', [123, "123arcmin", "123arcsec", "123deg", "123degree", "123degrees", "123 arcmin", "123 arcsec", "123 deg", "123 degree", "123 degrees", "123", "123\"", "123'"]) @@ -29,3 +29,35 @@ def test_coordinate_invalid(val): with pytest.raises(ValueError) as e: v.validate(val, None) assert "not a number, a string in H:M:S or D:M:S format, or a numeric string with degree units" in str(e.value) + + +@pytest.mark.parametrize('clazz,val', [ + (Point.NumericPoint, (123, 123)), + (Point.WorldCoordinatePoint, ("123deg", "123deg")), + (Point.AngularSizePoint, ("123'", "123'")), + (Point.CoordinatePoint, (123, 123)), + (Point.CoordinatePoint, ("123deg", "123deg")), + (Point.SizePoint, (123, 123)), + (Point.SizePoint, ("123'", "123'")), + (Point, (123, 123)), + (Point, ("123'", "123'")), + (Point, ("123deg", "123deg")), +]) +def test_point_valid(clazz, val): + clazz().validate(val, None) + + +@pytest.mark.parametrize('clazz,val,error_contains', [ + (Point, (123, "123"), "not a pair of numbers, coordinate strings, or size strings"), + (Point.CoordinatePoint, (123, "123"), "not a pair of numbers or coordinate strings"), + (Point.SizePoint, (123, "123"), "a pair of numbers or size strings"), + (Point.NumericPoint, ("123", "123"), "not a pair of numbers"), + (Point.WorldCoordinatePoint, ("123'", "123'"), "not a pair of coordinate strings"), + (Point.WorldCoordinatePoint, (123, 123), "not a pair of coordinate strings"), + (Point.AngularSizePoint, ("12:34", "12:34"), "not a pair of size strings"), + (Point.AngularSizePoint, (123, 123), "not a pair of size strings"), +]) +def test_point_invalid(clazz, val, error_contains): + with pytest.raises(ValueError) as e: + clazz().validate(val, None) + assert error_contains in str(e.value) From ab065d2fc91a73c87a20160317bab34edca0bb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 23 Oct 2023 16:30:27 +0200 Subject: [PATCH 28/50] Added tests for angular size arcsec conversion --- tests/test_units.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_units.py b/tests/test_units.py index 15dd198..6f632d2 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -242,6 +242,26 @@ def test_angular_size_from_string_all_invalid(size): assert "not in a recognized angular size format" in str(e.value) +@pytest.mark.parametrize("val,norm", [ + (10800, "3deg"), + (6000, "100'"), + (100, "100\""), + (0.001, "0.001\""), +]) +def test_angular_size_from_arcsec(val, norm): + assert str(AngularSize.from_arcsec(val)) == norm + + +@pytest.mark.parametrize("size,val", [ + ("3deg", 10800), + ("100'", 6000), + ("100\"", 100), + ("1mas", 0.001), +]) +def test_angular_size_arcsec(size, val): + assert AngularSize.from_string(size).arcsec == val + + @pytest.mark.parametrize("coord,valid", [ ("0deg", True), ("123 degrees", True), From 36ce9868cde2cb748b01c169d18c1c974c8fa850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 23 Oct 2023 22:10:49 +0200 Subject: [PATCH 29/50] Simplified region class attributes. Added some docstrings to class attributes. --- carta/region.py | 29 ++++++++++++++++++----------- carta/units.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/carta/region.py b/carta/region.py index ff551ee..769b656 100644 --- a/carta/region.py +++ b/carta/region.py @@ -459,17 +459,14 @@ class Region(BasePathMixin): The session object associated with this region. """ - REGION_TYPE = None CUSTOM_CLASS = {} + """Mapping of :obj:`carta.constants.RegionType` types to region and annotation classes. This mapping is used to select the appropriate subclass when a region or annotation object is constructed in the wrapper.""" def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) - if cls.REGION_TYPE is not None: - Region.CUSTOM_CLASS[cls.REGION_TYPE] = cls - elif cls.REGION_TYPES is not None: - for t in cls.REGION_TYPES: - Region.CUSTOM_CLASS[t] = cls + for t in cls.REGION_TYPES: + Region.CUSTOM_CLASS[t] = cls def __init__(self, region_set, region_id): self.region_set = region_set @@ -1141,21 +1138,25 @@ def set_pointer_style(self, pointer_width=None, pointer_length=None): class LineRegion(HasEndpointsMixin, HasRotationMixin, Region): """A line region or annotation.""" REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) + """The region types corresponding to this class.""" class PolylineRegion(HasVerticesMixin, Region): """A polyline region or annotation.""" REGION_TYPES = (RegionType.POLYLINE, RegionType.ANNPOLYLINE) + """The region types corresponding to this class.""" class PolygonRegion(HasVerticesMixin, Region): """A polygonal region or annotation.""" REGION_TYPES = (RegionType.POLYGON, RegionType.ANNPOLYGON) + """The region types corresponding to this class.""" class RectangularRegion(HasRotationMixin, Region): """A rectangular region or annotation.""" REGION_TYPES = (RegionType.RECTANGLE, RegionType.ANNRECTANGLE) + """The region types corresponding to this class.""" # GET PROPERTIES @@ -1231,6 +1232,7 @@ def set_corners(self, bottom_left=None, top_right=None): class EllipticalRegion(HasRotationMixin, Region): """An elliptical region or annotation.""" REGION_TYPES = (RegionType.ELLIPSE, RegionType.ANNELLIPSE) + """The region types corresponding to this class.""" # GET PROPERTIES @@ -1332,7 +1334,8 @@ def set_size(self, size): class PointAnnotation(Region): """A point annotation.""" - REGION_TYPE = RegionType.ANNPOINT + REGION_TYPES = (RegionType.ANNPOINT,) + """The region types corresponding to this class.""" # GET PROPERTIES @@ -1381,7 +1384,8 @@ def set_point_style(self, point_shape=None, point_width=None): class TextAnnotation(HasFontMixin, HasRotationMixin, Region): """A text annotation.""" - REGION_TYPE = RegionType.ANNTEXT + REGION_TYPES = (RegionType.ANNTEXT,) + """The region types corresponding to this class.""" # GET PROPERTIES @@ -1434,12 +1438,14 @@ def set_text_position(self, text_position): class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, Region): """A vector annotation.""" - REGION_TYPE = RegionType.ANNVECTOR + REGION_TYPES = (RegionType.ANNVECTOR,) + """The region types corresponding to this class.""" class CompassAnnotation(HasFontMixin, HasPointerMixin, Region): """A compass annotation.""" - REGION_TYPE = RegionType.ANNCOMPASS + REGION_TYPES = (RegionType.ANNCOMPASS,) + """The region types corresponding to this class.""" # GET PROPERTIES @@ -1598,7 +1604,8 @@ def set_arrowhead_visible(self, north=None, east=None): class RulerAnnotation(HasFontMixin, HasEndpointsMixin, Region): """A ruler annotation.""" - REGION_TYPE = RegionType.ANNRULER + REGION_TYPES = (RegionType.ANNRULER,) + """The region types corresponding to this class.""" # GET PROPERTIES diff --git a/carta/units.py b/carta/units.py index d7b45b1..2026857 100644 --- a/carta/units.py +++ b/carta/units.py @@ -14,7 +14,13 @@ class AngularSize: Child class instances have a string representation in a normalized format which can be parsed by the frontend. """ FORMATS = {} + """A mapping of units to angular size subclasses.""" NAME = "angular size" + """A descriptive name.""" + SYMBOL_UNIT_REGEX = "" + """All recognised input units which are a single character long.""" + WORD_UNIT_REGEX = "" + """All recognised input units which are multiple characters long.""" def __init__(self, value): self.value = value @@ -146,53 +152,80 @@ def arcsec(self): class DegreesSize(AngularSize): """An angular size in degrees.""" NAME = "degree" + """A descriptive name.""" INPUT_UNITS = {"deg", "degree", "degrees"} + """All recognised input unit strings.""" OUTPUT_UNIT = "deg" + """The canonical output unit string.""" FACTOR = 1 + """The scaling factor to use when representing the value using the output unit.""" ARCSEC_FACTOR = 3600 + """The scaling factor to use when converting the value to arcseconds.""" class ArcminSize(AngularSize): """An angular size in arcminutes.""" NAME = "arcminute" + """A descriptive name.""" INPUT_UNITS = {"'", "arcminutes", "arcminute", "arcmin", "amin", "′"} + """All recognised input unit strings.""" OUTPUT_UNIT = "'" + """The canonical output unit string.""" FACTOR = 1 + """The scaling factor to use when representing the value using the output unit.""" ARCSEC_FACTOR = 60 + """The scaling factor to use when converting the value to arcseconds.""" class ArcsecSize(AngularSize): """An angular size in arcseconds.""" NAME = "arcsecond" + """A descriptive name.""" INPUT_UNITS = {"\"", "", "arcseconds", "arcsecond", "arcsec", "asec", "″"} + """All recognised input unit strings.""" OUTPUT_UNIT = "\"" + """The canonical output unit string.""" FACTOR = 1 + """The scaling factor to use when representing the value using the output unit.""" ARCSEC_FACTOR = FACTOR + """The scaling factor to use when converting the value to arcseconds.""" class MilliarcsecSize(AngularSize): """An angular size in milliarcseconds.""" NAME = "milliarcsecond" + """A descriptive name.""" INPUT_UNITS = {"milliarcseconds", "milliarcsecond", "milliarcsec", "mas"} + """All recognised input unit strings.""" OUTPUT_UNIT = "\"" + """The canonical output unit string.""" FACTOR = 1e-3 + """The scaling factor to use when representing the value using the output unit.""" ARCSEC_FACTOR = FACTOR + """The scaling factor to use when converting the value to arcseconds.""" class MicroarcsecSize(AngularSize): """An angular size in microarcseconds.""" NAME = "microarcsecond" + """A descriptive name.""" INPUT_UNITS = {"microarcseconds", "microarcsecond", "microarcsec", "µas", "uas"} + """All recognised input unit strings.""" OUTPUT_UNIT = "\"" + """The canonical output unit string.""" FACTOR = 1e-6 + """The scaling factor to use when representing the value using the output unit.""" ARCSEC_FACTOR = FACTOR + """The scaling factor to use when converting the value to arcseconds.""" class WorldCoordinate: """A world coordinate.""" FMT = None + """The number format.""" FORMATS = {} + """A mapping of number formats to world coordinate subclasses.""" def __init_subclass__(cls, **kwargs): """Automatically register subclasses corresponding to number formats.""" @@ -251,11 +284,14 @@ def from_string(cls, value, axis): class DegreesCoordinate(WorldCoordinate): """A world coordinate in decimal degree format.""" FMT = NumberFormat.DEGREES + """The number format.""" DEGREE_UNITS = DegreesSize.INPUT_UNITS + """All recognised degree units.""" REGEX = { "DEGREE_UNIT": rf"^-?(\d+(?:\.\d+)?)\s*({'|'.join(DEGREE_UNITS)})$", "DECIMAL": r"^-?\d+(\.\d+)?$", } + """All recognised input string formats.""" @classmethod def from_string(cls, value, axis): @@ -353,11 +389,13 @@ def as_tuple(self): class HMSCoordinate(SexagesimalCoordinate): """A world coordinate in HMS format.""" FMT = NumberFormat.HMS + """The number format.""" # Temporarily allow negative H values to account for frontend custom format oddity REGEX = { "COLON": r"^(-?(?:\d|[01]\d|2[0-3]))?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", "LETTER": r"^(?:(-?(?:\d|[01]\d|2[0-3]))h)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", } + """All recognised input string formats.""" @classmethod def from_string(cls, value, axis): @@ -392,10 +430,12 @@ def from_string(cls, value, axis): class DMSCoordinate(SexagesimalCoordinate): """A world coordinate in DMS format.""" FMT = NumberFormat.DMS + """The number format.""" REGEX = { "COLON": r"^(-?\d+)?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", "LETTER": r"^(?:(-?\d+)d)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", } + """All recognised input string formats.""" @classmethod def from_string(cls, value, axis): From 6e2eb245dd676502a66d97cb7e38663fcfac45a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 27 Oct 2023 11:04:19 +0200 Subject: [PATCH 30/50] removed angle correction which should be unnecessary for atan2 --- carta/region.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/carta/region.py b/carta/region.py index 769b656..c7c810b 100644 --- a/carta/region.py +++ b/carta/region.py @@ -1656,8 +1656,6 @@ def rotation(self): ((sx, sy), (ex, ey)) = self.endpoints rad = math.atan2(ex - sx, sy - ey) rotation = math.degrees(rad) - if ey > sy: - rotation += 180 rotation = (rotation + 360) % 360 return rotation From 34f08b373590b8dacbf3dead93af2cdd5158c7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 27 Oct 2023 14:25:55 +0200 Subject: [PATCH 31/50] negate size returned by frontend for endpoint regions instead of negating size to set --- carta/region.py | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/carta/region.py b/carta/region.py index c7c810b..16f7a0f 100644 --- a/carta/region.py +++ b/carta/region.py @@ -920,6 +920,35 @@ class HasEndpointsMixin: # GET PROPERTIES + @property + def size(self): + """The size, in pixels. + + Returns + ------- + number + The width. + number + The height. + """ + sx, sy = super().size + # negate the raw frontend value for consistency + return (-sx, -sy) + + @property + def wcs_size(self): + """The size, in angular size units. + + Returns + ------- + string + The width. + string + The height. + """ + [size] = self.region_set.image.to_angular_size_points([self.size]) + return size + @property def endpoints(self): """The endpoints, in image coordinates. @@ -969,21 +998,6 @@ def wcs_length(self): # SET PROPERTIES - @validate(Point.SizePoint()) - def set_size(self, size): - """Set the size. - - Both pixel and angular sizes are accepted, but both values must match. - - Parameters - ---------- - size : {0} - The new width and height, in that order. - """ - [size] = self.region_set._from_angular_sizes([size]) - sx, sy = size - super().set_size((-sx, -sy)) # negated for consistency with returned size - @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) def set_endpoints(self, start=None, end=None): """Update the endpoints. @@ -1725,7 +1739,7 @@ def set_size(self, size): start = cx - dx / 2, cy - dy / 2 end = cx + dx / 2, cy + dy / 2 - self.set_control_points([end, start]) # reversed for consistency with returned size + self.set_control_points([start, end]) @validate(*all_optional(Boolean(), Number())) def set_auxiliary_lines_style(self, visible=None, dash_length=None): From 2ad608e95cdb78c10f65daea0326cc2f66d77c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 27 Oct 2023 14:52:19 +0200 Subject: [PATCH 32/50] Fixed tests for swapped size, and renamed fixtures to make later merge easier --- tests/test_image.py | 100 ++++++------- tests/test_region.py | 337 +++++++++++++++++++++--------------------- tests/test_session.py | 98 ++++++------ 3 files changed, 267 insertions(+), 268 deletions(-) diff --git a/tests/test_image.py b/tests/test_image.py index 9e15453..21f3e51 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -26,25 +26,25 @@ def image(session): @pytest.fixture -def mock_get_value(image, mocker): +def get_value(image, mocker): """Return a mock for image's get_value.""" return mocker.patch.object(image, "get_value") @pytest.fixture -def mock_call_action(image, mocker): +def call_action(image, mocker): """Return a mock for image's call_action.""" return mocker.patch.object(image, "call_action") @pytest.fixture -def mock_session_call_action(session, mocker): +def session_call_action(session, mocker): """Return a mock for session's call_action.""" return mocker.patch.object(session, "call_action") @pytest.fixture -def mock_property(mocker): +def property_(mocker): """Return a helper function to mock the value of a decorated image property using a simple syntax.""" def func(property_name, mock_value): return mocker.patch(f"carta.image.Image.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) @@ -52,7 +52,7 @@ def func(property_name, mock_value): @pytest.fixture -def mock_method(image, mocker): +def method(image, mocker): """Return a helper function to mock the return value(s) of an image method using a simple syntax.""" def func(method_name, return_values): return mocker.patch.object(image, method_name, side_effect=return_values) @@ -60,7 +60,7 @@ def func(method_name, return_values): @pytest.fixture -def mock_session_method(session, mocker): +def session_method(session, mocker): """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" def func(method_name, return_values): return mocker.patch.object(session, method_name, side_effect=return_values) @@ -119,13 +119,13 @@ def test_image_properties_have_docstrings(member): (["subdir", "image.fits", "", True, False], {"make_active": False}, ["appendFile", "/my_data/subdir", "image.fits", "", False, False, False]), ]) -def test_new(session, mock_session_call_action, mock_session_method, args, kwargs, expected_params): - mock_session_method("pwd", ["/my_data"]) - mock_session_call_action.side_effect = [123] +def test_new(session, session_call_action, session_method, args, kwargs, expected_params): + session_method("pwd", ["/my_data"]) + session_call_action.side_effect = [123] image_object = Image.new(session, *args, **kwargs) - mock_session_call_action.assert_called_with(*expected_params, return_path='frameInfo.fileId') + session_call_action.assert_called_with(*expected_params, return_path='frameInfo.fileId') assert type(image_object) is Image assert image_object.session == session @@ -139,24 +139,24 @@ def test_new(session, mock_session_call_action, mock_session_method, args, kwarg ("directory", "frameInfo.directory"), ("width", "frameInfo.fileInfoExtended.width"), ]) -def test_simple_properties(image, property_name, expected_path, mock_get_value): +def test_simple_properties(image, property_name, expected_path, get_value): getattr(image, property_name) - mock_get_value.assert_called_with(expected_path) + get_value.assert_called_with(expected_path) # TODO tests for all existing functions to be filled in -def test_make_active(image, mock_session_call_action): +def test_make_active(image, session_call_action): image.make_active() - mock_session_call_action.assert_called_with("setActiveFrameById", 0) + session_call_action.assert_called_with("setActiveFrameById", 0) @pytest.mark.parametrize("channel", [0, 10, 19]) -def test_set_channel_valid(image, channel, mock_call_action, mock_property): - mock_property("depth", 20) +def test_set_channel_valid(image, channel, call_action, property_): + property_("depth", 20) image.set_channel(channel) - mock_call_action.assert_called_with("setChannels", channel, image.macro("", "requiredStokes"), True) + call_action.assert_called_with("setChannels", channel, image.macro("", "requiredStokes"), True) @pytest.mark.parametrize("channel,error_contains", [ @@ -164,8 +164,8 @@ def test_set_channel_valid(image, channel, mock_call_action, mock_property): (1.5, "not an increment of 1"), (-3, "must be greater or equal"), ]) -def test_set_channel_invalid(image, channel, error_contains, mock_property): - mock_property("depth", 20) +def test_set_channel_invalid(image, channel, error_contains, property_): + property_("depth", 20) with pytest.raises(CartaValidationFailed) as e: image.set_channel(channel) @@ -174,13 +174,13 @@ def test_set_channel_invalid(image, channel, error_contains, mock_property): @pytest.mark.parametrize("x", [-30, 0, 10, 12.3, 30]) @pytest.mark.parametrize("y", [-30, 0, 10, 12.3, 30]) -def test_set_center_valid_pixels(image, mock_property, mock_call_action, x, y): +def test_set_center_valid_pixels(image, property_, call_action, x, y): # Currently we have no range validation, for consistency with WCS coordinates. - mock_property("width", 20) - mock_property("height", 20) + property_("width", 20) + property_("height", 20) image.set_center(x, y) - mock_call_action.assert_called_with("setCenter", x, y) + call_action.assert_called_with("setCenter", x, y) @pytest.mark.parametrize("x,y,x_fmt,y_fmt,x_norm,y_norm", [ @@ -191,12 +191,12 @@ def test_set_center_valid_pixels(image, mock_property, mock_call_action, x, y): ("12h34m56.789s", "5h34m56.789s", NF.HMS, NF.HMS, "12:34:56.789", "5:34:56.789"), ("12d34m56.789s", "12d34m56.789s", NF.DMS, NF.DMS, "12:34:56.789", "12:34:56.789"), ]) -def test_set_center_valid_wcs(image, mock_property, mock_session_method, mock_call_action, x, y, x_fmt, y_fmt, x_norm, y_norm): - mock_property("valid_wcs", True) - mock_session_method("number_format", [(x_fmt, y_fmt, None)]) +def test_set_center_valid_wcs(image, property_, session_method, call_action, x, y, x_fmt, y_fmt, x_norm, y_norm): + property_("valid_wcs", True) + session_method("number_format", [(x_fmt, y_fmt, None)]) image.set_center(x, y) - mock_call_action.assert_called_with("setCenterWcs", x_norm, y_norm) + call_action.assert_called_with("setCenterWcs", x_norm, y_norm) @pytest.mark.parametrize("x,y,wcs,x_fmt,y_fmt,error_contains", [ @@ -207,11 +207,11 @@ def test_set_center_valid_wcs(image, mock_property, mock_session_method, mock_ca (123, "123", True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), ("123", 123, True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), ]) -def test_set_center_invalid(image, mock_property, mock_session_method, mock_call_action, x, y, wcs, x_fmt, y_fmt, error_contains): - mock_property("width", 200) - mock_property("height", 200) - mock_property("valid_wcs", wcs) - mock_session_method("number_format", [(x_fmt, y_fmt, None)]) +def test_set_center_invalid(image, property_, session_method, call_action, x, y, wcs, x_fmt, y_fmt, error_contains): + property_("width", 200) + property_("height", 200) + property_("valid_wcs", wcs) + session_method("number_format", [(x_fmt, y_fmt, None)]) with pytest.raises(Exception) as e: image.set_center(x, y) @@ -228,10 +228,10 @@ def test_set_center_invalid(image, mock_property, mock_session_method, mock_call ("123deg", "zoomToSize{0}Wcs", "123deg"), ("123 deg", "zoomToSize{0}Wcs", "123deg"), ]) -def test_zoom_to_size(image, mock_property, mock_call_action, axis, val, action, norm): - mock_property("valid_wcs", True) +def test_zoom_to_size(image, property_, call_action, axis, val, action, norm): + property_("valid_wcs", True) image.zoom_to_size(val, axis) - mock_call_action.assert_called_with(action.format(axis.upper()), norm) + call_action.assert_called_with(action.format(axis.upper()), norm) @pytest.mark.parametrize("axis", [SA.X, SA.Y]) @@ -240,17 +240,17 @@ def test_zoom_to_size(image, mock_property, mock_call_action, axis, val, action, ("abc", True, "Invalid function parameter"), ("123arcsec", False, "does not contain valid WCS information"), ]) -def test_zoom_to_size_invalid(image, mock_property, axis, val, wcs, error_contains): - mock_property("valid_wcs", wcs) +def test_zoom_to_size_invalid(image, property_, axis, val, wcs, error_contains): + property_("valid_wcs", wcs) with pytest.raises(Exception) as e: image.zoom_to_size(val, axis) assert error_contains in str(e.value) -def test_from_world_coordinate_points(image, mock_call_action): - mock_call_action.return_value = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}] +def test_from_world_coordinate_points(image, call_action): + call_action.return_value = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}] points = image.from_world_coordinate_points([("1", "2"), ("3", "4"), ("5", "6")]) - mock_call_action.assert_called_with("getImagePosFromWCS", [Pt("1", "2"), Pt("3", "4"), Pt("5", "6")]) + call_action.assert_called_with("getImagePosFromWCS", [Pt("1", "2"), Pt("3", "4"), Pt("5", "6")]) assert points == [(1, 2), (3, 4), (5, 6)] @@ -260,10 +260,10 @@ def test_from_world_coordinate_points_invalid(image): assert "not a pair of coordinate strings" in str(e.value) -def test_to_world_coordinate_points(image, mock_call_action): - mock_call_action.return_value = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}, {"x": "5", "y": "6"}] +def test_to_world_coordinate_points(image, call_action): + call_action.return_value = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}, {"x": "5", "y": "6"}] points = image.to_world_coordinate_points([(1, 2), (3, 4), (5, 6)]) - mock_call_action.assert_called_with("getWCSFromImagePos", [Pt(1, 2), Pt(3, 4), Pt(5, 6)]) + call_action.assert_called_with("getWCSFromImagePos", [Pt(1, 2), Pt(3, 4), Pt(5, 6)]) assert points == [("1", "2"), ("3", "4"), ("5", "6")] @@ -277,9 +277,9 @@ def test_to_world_coordinate_points_invalid(image): ("100\"", SA.X, ("getImageXValueFromArcsec", 100)), ("100\"", SA.Y, ("getImageYValueFromArcsec", 100)), ]) -def test_from_angular_size(image, mock_call_action, size, axis, expected_call): +def test_from_angular_size(image, call_action, size, axis, expected_call): image.from_angular_size(size, axis) - mock_call_action.assert_called_with(*expected_call) + call_action.assert_called_with(*expected_call) @pytest.mark.parametrize("size,error_contains", [ @@ -292,8 +292,8 @@ def test_from_angular_size_invalid(image, size, error_contains): assert error_contains in str(e.value) -def test_from_angular_size_points(mocker, image, mock_method): - mock_from_angular_size = mock_method("from_angular_size", [1, 2, 3, 4]) +def test_from_angular_size_points(mocker, image, method): + mock_from_angular_size = method("from_angular_size", [1, 2, 3, 4]) points = image.from_angular_size_points([("1", "2"), ("3", "4")]) mock_from_angular_size.assert_has_calls([ mocker.call("1", SA.X), @@ -304,10 +304,10 @@ def test_from_angular_size_points(mocker, image, mock_method): assert points == [(1, 2), (3, 4)] -def test_to_angular_size_points(mocker, image, mock_call_action): - mock_call_action.side_effect = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}] +def test_to_angular_size_points(mocker, image, call_action): + call_action.side_effect = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}] points = image.to_angular_size_points([(1, 2), (3, 4)]) - mock_call_action.assert_has_calls([ + call_action.assert_has_calls([ mocker.call("getWcsSizeInArcsec", Pt(1, 2)), mocker.call("getWcsSizeInArcsec", Pt(3, 4)), ]) diff --git a/tests/test_region.py b/tests/test_region.py index aef9088..aeb71fd 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -31,13 +31,13 @@ def image(session): @pytest.fixture -def mock_session_call_action(session, mocker): +def session_call_action(session, mocker): """Return a mock for session's call_action.""" return mocker.patch.object(session, "call_action") @pytest.fixture -def mock_session_method(session, mocker): +def session_method(session, mocker): """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" def func(method_name, return_values): return mocker.patch.object(session, method_name, side_effect=return_values) @@ -45,7 +45,7 @@ def func(method_name, return_values): @pytest.fixture -def mock_image_method(image, mocker): +def image_method(image, mocker): """Return a helper function to mock the return value(s) of an image method using a simple syntax.""" def func(method_name, return_values): return mocker.patch.object(image, method_name, side_effect=return_values) @@ -53,32 +53,32 @@ def func(method_name, return_values): @pytest.fixture -def mock_to_world(mock_image_method): - return mock_image_method("to_world_coordinate_points", lambda l: [(str(x), str(y)) for (x, y) in l]) +def mock_to_world(image_method): + return image_method("to_world_coordinate_points", lambda l: [(str(x), str(y)) for (x, y) in l]) @pytest.fixture -def mock_to_angular(mock_image_method): - return mock_image_method("to_angular_size_points", lambda l: [(str(x), str(y)) for (x, y) in l]) +def mock_to_angular(image_method): + return image_method("to_angular_size_points", lambda l: [(str(x), str(y)) for (x, y) in l]) # Regionset mocks @pytest.fixture -def mock_regionset_get_value(image, mocker): +def regionset_get_value(image, mocker): """Return a mock for image.regions' get_value.""" return mocker.patch.object(image.regions, "get_value") @pytest.fixture -def mock_regionset_call_action(image, mocker): +def regionset_call_action(image, mocker): """Return a mock for image.regions' call_action.""" return mocker.patch.object(image.regions, "call_action") @pytest.fixture -def mock_regionset_method(image, mocker): +def regionset_method(image, mocker): """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" def func(method_name, return_values): return mocker.patch.object(image.regions, method_name, side_effect=return_values) @@ -86,13 +86,13 @@ def func(method_name, return_values): @pytest.fixture -def mock_from_world(mock_regionset_method): - return mock_regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) +def mock_from_world(regionset_method): + return regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) @pytest.fixture -def mock_from_angular(mock_regionset_method): - return mock_regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) +def mock_from_angular(regionset_method): + return regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) # The region-specific mocks are all factories, so that they can be used to mock different region subclasses (specified by region type) @@ -111,7 +111,7 @@ def func(region_type=None, region_id=0): @pytest.fixture -def mock_get_value(mocker): +def get_value(mocker): """Return a factory for a mock for a region object's get_value.""" def func(region, mock_value=None): return mocker.patch.object(region, "get_value", return_value=mock_value) @@ -119,7 +119,7 @@ def func(region, mock_value=None): @pytest.fixture -def mock_call_action(mocker): +def call_action(mocker): """Return a factory for a mock for a region object's call_action.""" def func(region): return mocker.patch.object(region, "call_action") @@ -127,7 +127,7 @@ def func(region): @pytest.fixture -def mock_property(mocker): +def property_(mocker): """Return a factory for a mock for a region object's decorated property.""" def func(region, property_name, mock_value=None): class_name = region.__class__.__name__ # Is this going to work? Do we need to import it? @@ -136,7 +136,7 @@ def func(region, property_name, mock_value=None): @pytest.fixture -def mock_method(mocker): +def method(mocker): """Return a factory for a mock for a region object's method.""" def func(region, method_name, return_values): return mocker.patch.object(region, method_name, side_effect=return_values) @@ -200,27 +200,27 @@ def test_region_properties_have_docstrings(member): (True, [2, 3]), (False, [1, 2, 3]), ]) -def test_regionset_list(mocker, image, mock_regionset_get_value, ignore_cursor, expected_items): - mock_regionset_get_value.side_effect = [[1, 2, 3]] +def test_regionset_list(mocker, image, regionset_get_value, ignore_cursor, expected_items): + regionset_get_value.side_effect = [[1, 2, 3]] mock_from_list = mocker.patch.object(Region, "from_list") image.regions.list(ignore_cursor) - mock_regionset_get_value.assert_called_with("regionList") + regionset_get_value.assert_called_with("regionList") mock_from_list.assert_called_with(image.regions, expected_items) -def test_regionset_get(mocker, image, mock_regionset_get_value): - mock_regionset_get_value.side_effect = [RT.RECTANGLE] +def test_regionset_get(mocker, image, regionset_get_value): + regionset_get_value.side_effect = [RT.RECTANGLE] mock_existing = mocker.patch.object(Region, "existing") image.regions.get(1) - mock_regionset_get_value.assert_called_with("regionMap[1]", return_path="regionType") + regionset_get_value.assert_called_with("regionMap[1]", return_path="regionType") mock_existing.assert_called_with(RT.RECTANGLE, image.regions, 1) -def test_regionset_import_from(mocker, image, mock_session_method, mock_session_call_action): - mock_session_method("resolve_file_path", ["/path/to/directory/"]) - mock_session_call_action.side_effect = [FT.CRTF, None] +def test_regionset_import_from(mocker, image, session_method, session_call_action): + session_method("resolve_file_path", ["/path/to/directory/"]) + session_call_action.side_effect = [FT.CRTF, None] image.regions.import_from("input_region_file") - mock_session_call_action.assert_has_calls([ + session_call_action.assert_has_calls([ mocker.call("backendService.getRegionFileInfo", "/path/to/directory/", "input_region_file", return_path="fileInfo.type"), mocker.call("importRegion", "/path/to/directory/", "input_region_file", FT.CRTF, image._frame), ]) @@ -229,11 +229,11 @@ def test_regionset_import_from(mocker, image, mock_session_method, mock_session_ @pytest.mark.parametrize("coordinate_type", [CT.PIXEL, CT.WORLD]) @pytest.mark.parametrize("file_type", [FT.CRTF, FT.DS9_REG]) @pytest.mark.parametrize("region_ids,expected_region_ids", [(None, [2, 3, 4]), ([2, 3], [2, 3]), ([4], [4])]) -def test_regionset_export_to(mocker, image, mock_session_method, mock_session_call_action, mock_regionset_get_value, coordinate_type, file_type, region_ids, expected_region_ids): - mock_session_method("resolve_file_path", ["/path/to/directory/"]) - mock_regionset_get_value.side_effect = [[{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]] +def test_regionset_export_to(mocker, image, session_method, session_call_action, regionset_get_value, coordinate_type, file_type, region_ids, expected_region_ids): + session_method("resolve_file_path", ["/path/to/directory/"]) + regionset_get_value.side_effect = [[{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]] image.regions.export_to("output_region_file", coordinate_type, file_type, region_ids) - mock_session_call_action.assert_called_with("exportRegions", "/path/to/directory/", "output_region_file", coordinate_type, file_type, expected_region_ids, image._frame) + session_call_action.assert_called_with("exportRegions", "/path/to/directory/", "output_region_file", coordinate_type, file_type, expected_region_ids, image._frame) def test_regionset_add_region(mocker, image): @@ -298,8 +298,8 @@ def test_regionset_add_region(mocker, image): ("add_ruler", [("10", "10"), ("20", "20")], {}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": ""}), ("add_ruler", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": "my region"}), ]) -def test_regionset_add_region_with_type(mocker, image, mock_regionset_method, mock_from_world, mock_from_angular, region, func, args, kwargs, expected_args, expected_kwargs): - mock_add_region = mock_regionset_method("add_region", None) +def test_regionset_add_region_with_type(mocker, image, regionset_method, mock_from_world, mock_from_angular, region, func, args, kwargs, expected_args, expected_kwargs): + mock_add_region = regionset_method("add_region", None) if func == "add_text": text_annotation = region(region_type=RT.ANNTEXT) @@ -314,10 +314,10 @@ def test_regionset_add_region_with_type(mocker, image, mock_regionset_method, mo mock_set_text.assert_called_with(args[2]) -def test_regionset_clear(mocker, image, mock_regionset_method, mock_method, region): +def test_regionset_clear(mocker, image, regionset_method, method, region): regionlist = [region(), region(), region()] - mock_deletes = [mock_method(r, "delete", None) for r in regionlist] - mock_regionset_method("list", [regionlist]) + mock_deletes = [method(r, "delete", None) for r in regionlist] + regionset_method("list", [regionlist]) image.regions.clear() @@ -325,31 +325,31 @@ def test_regionset_clear(mocker, image, mock_regionset_method, mock_method, regi m.assert_called_with() -def test_region_type(image, mock_get_value): +def test_region_type(image, get_value): reg = Region(image.regions, 0) # Bypass the default to test the real region_type - reg_mock_get_value = mock_get_value(reg, 3) + reg_get_value = get_value(reg, 3) region_type = reg.region_type - reg_mock_get_value.assert_called_with("regionType") + reg_get_value.assert_called_with("regionType") assert region_type == RT.RECTANGLE @pytest.mark.parametrize("region_type", [t for t in RT]) -def test_center(region, mock_get_value, region_type): +def test_center(region, get_value, region_type): reg = region(region_type) - reg_mock_get_value = mock_get_value(reg, {"x": 20, "y": 30}) + reg_get_value = get_value(reg, {"x": 20, "y": 30}) center = reg.center - reg_mock_get_value.assert_called_with("center") + reg_get_value.assert_called_with("center") assert center == (20, 30) @pytest.mark.parametrize("region_type", [t for t in RT]) -def test_wcs_center(region, mock_property, mock_to_world, region_type): +def test_wcs_center(region, property_, mock_to_world, region_type): reg = region(region_type) - mock_property(reg, "center", (20, 30)) + property_(reg, "center", (20, 30)) wcs_center = reg.wcs_center @@ -358,54 +358,56 @@ def test_wcs_center(region, mock_property, mock_to_world, region_type): @pytest.mark.parametrize("region_type", [t for t in RT]) -def test_size(region, mock_get_value, region_type): +def test_size(region, get_value, region_type): reg = region(region_type) - if region_type in (RT.POINT, RT.ANNPOINT): - reg_mock_get_value = mock_get_value(reg, None) + if region_type in {RT.POINT, RT.ANNPOINT}: + reg_get_value = get_value(reg, None) else: - reg_mock_get_value = mock_get_value(reg, {"x": 20, "y": 30}) + reg_get_value = get_value(reg, {"x": 20, "y": 30}) size = reg.size - reg_mock_get_value.assert_called_with("size") - if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): + reg_get_value.assert_called_with("size") + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: assert size == (60, 40) # The frontend size returned for an ellipse is the semi-axes, which we double and swap - elif region_type in (RT.POINT, RT.ANNPOINT): + elif region_type in {RT.POINT, RT.ANNPOINT}: assert size is None # Test that returned null/undefined size for a point is converted to None as expected + elif region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: + assert size == (-20, -30) # Negate size returned by the frontend for endpoint regions else: assert size == (20, 30) @pytest.mark.parametrize("region_type", [t for t in RT]) -def test_wcs_size(region, mock_get_value, mock_property, mock_to_angular, region_type): +def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): reg = region(region_type) - if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE, RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: # Bypasses wcsSize to call own (overridden) size and converts to angular units - mock_property(reg, "size", (20, 30)) - elif region_type in (RT.POINT, RT.ANNPOINT): + property_(reg, "size", (20, 30)) + elif region_type in {RT.POINT, RT.ANNPOINT}: # Simulate undefined size - reg_mock_get_value = mock_get_value(reg, {"x": None, "y": None}) + reg_get_value = get_value(reg, {"x": None, "y": None}) else: - reg_mock_get_value = mock_get_value(reg, {"x": "20", "y": "30"}) + reg_get_value = get_value(reg, {"x": "20", "y": "30"}) size = reg.wcs_size - if region_type in (RT.ELLIPSE, RT.ANNELLIPSE): + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE, RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: mock_to_angular.assert_called_with([(20, 30)]) assert size == ("20", "30") - elif region_type in (RT.POINT, RT.ANNPOINT): - reg_mock_get_value.assert_called_with("wcsSize") + elif region_type in {RT.POINT, RT.ANNPOINT}: + reg_get_value.assert_called_with("wcsSize") assert size is None else: - reg_mock_get_value.assert_called_with("wcsSize") + reg_get_value.assert_called_with("wcsSize") assert size == ("20\"", "30\"") -def test_control_points(region, mock_get_value): +def test_control_points(region, get_value): reg = region() - mock_get_value(reg, [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}]) + get_value(reg, [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}]) points = reg.control_points assert points == [(1, 2), (3, 4), (5, 6)] @@ -416,9 +418,9 @@ def test_control_points(region, mock_get_value): ("line_width", "lineWidth"), ("dash_length", "dashLength"), ]) -def test_simple_properties(region, mock_get_value, method_name, value_name): +def test_simple_properties(region, get_value, method_name, value_name): reg = region() - mock_value_getter = mock_get_value(reg, "dummy") + mock_value_getter = get_value(reg, "dummy") value = getattr(reg, method_name) mock_value_getter.assert_called_with(value_name) assert value == "dummy" @@ -429,15 +431,15 @@ def test_simple_properties(region, mock_get_value, method_name, value_name): ((20, 30), Pt(20, 30)), (("20", "30"), Pt(20, 30)), ]) -def test_set_center(region, mock_from_world, mock_call_action, mock_method, mock_property, region_type, value, expected_value): +def test_set_center(region, mock_from_world, call_action, method, property_, region_type, value, expected_value): reg = region(region_type) if region_type == RT.ANNRULER: - mock_property(reg, "size", (-10, -10)) - mock_property(reg, "rotation", 135) - mock_set_points = mock_method(reg, "set_control_points", None) + property_(reg, "size", (-10, -10)) + property_(reg, "rotation", 135) + mock_set_points = method(reg, "set_control_points", None) else: - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.set_center(value) @@ -452,46 +454,45 @@ def test_set_center(region, mock_from_world, mock_call_action, mock_method, mock ((20, 30), Pt(20, 30)), (("20", "30"), Pt(20, 30)), ]) -def test_set_size(region, mock_from_angular, mock_call_action, mock_method, mock_property, region_type, value, expected_value): +def test_set_size(region, mock_from_angular, call_action, method, property_, region_type, value, expected_value): reg = region(region_type) if region_type == RT.ANNRULER: - mock_set_points = mock_method(reg, "set_control_points", None) - mock_property(reg, "center", (10, 10)) + mock_set_points = method(reg, "set_control_points", None) + property_(reg, "center", (10, 10)) else: - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.set_size(value) if region_type == RT.ANNRULER: - mock_set_points.assert_called_with([(20.0, 25.0), (0.0, -5.0)]) + #mock_set_points.assert_called_with([(20.0, 25.0), (0.0, -5.0)]) + mock_set_points.assert_called_with([(0.0, -5.0), (20.0, 25.0)]) elif region_type == RT.ANNCOMPASS: mock_call.assert_called_with("setLength", min(expected_value.x, expected_value.y)) - elif region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR}: - mock_call.assert_called_with("setSize", Pt(- expected_value.x, - expected_value.y)) elif region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: mock_call.assert_called_with("setSize", Pt(expected_value.y / 2, expected_value.x / 2)) else: mock_call.assert_called_with("setSize", expected_value) -def test_set_control_point(region, mock_call_action): +def test_set_control_point(region, call_action): reg = region() - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.set_control_point(3, (20, 30)) mock_call.assert_called_with("setControlPoint", 3, Pt(20, 30)) -def test_set_control_points(region, mock_call_action): +def test_set_control_points(region, call_action): reg = region() - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.set_control_points([(20, 30), (40, 50)]) mock_call.assert_called_with("setControlPoints", [Pt(20, 30), Pt(40, 50)]) -def test_set_name(region, mock_call_action): +def test_set_name(region, call_action): reg = region() - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.set_name("My region name") mock_call.assert_called_with("setName", "My region name") @@ -502,30 +503,30 @@ def test_set_name(region, mock_call_action): (["blue"], {"dash_length": 3}, [("setColor", "blue"), ("setDashLength", 3)]), ([], {"line_width": 2}, [("setLineWidth", 2)]), ]) -def test_set_line_style(mocker, region, mock_call_action, args, kwargs, expected_calls): +def test_set_line_style(mocker, region, call_action, args, kwargs, expected_calls): reg = region() - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.set_line_style(*args, **kwargs) mock_call.assert_has_calls([mocker.call(*c) for c in expected_calls]) -def test_lock(region, mock_call_action): +def test_lock(region, call_action): reg = region() - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.lock() mock_call.assert_called_with("setLocked", True) -def test_unlock(region, mock_call_action): +def test_unlock(region, call_action): reg = region() - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.unlock() mock_call.assert_called_with("setLocked", False) -def test_focus(region, mock_call_action): +def test_focus(region, call_action): reg = region() - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.focus() mock_call.assert_called_with("focusCenter") @@ -536,29 +537,29 @@ def test_focus(region, mock_call_action): (["/path/to/file"], {"coordinate_type": CT.PIXEL}, ["/path/to/file", CT.PIXEL, FT.CRTF]), (["/path/to/file"], {"file_type": FT.DS9_REG}, ["/path/to/file", CT.WORLD, FT.DS9_REG]), ]) -def test_export_to(region, mock_regionset_method, args, kwargs, expected_params): +def test_export_to(region, regionset_method, args, kwargs, expected_params): reg = region() - mock_export = mock_regionset_method("export_to", None) + mock_export = regionset_method("export_to", None) reg.export_to(*args, **kwargs) mock_export.assert_called_with(*expected_params, [reg.region_id]) -def test_delete(region, mock_regionset_call_action): +def test_delete(region, regionset_call_action): reg = region() reg.delete() - mock_regionset_call_action.assert_called_with("deleteRegion", Macro("", f"{reg.region_set._base_path}.regionMap[{reg.region_id}]")) + regionset_call_action.assert_called_with("deleteRegion", Macro("", f"{reg.region_set._base_path}.regionMap[{reg.region_id}]")) @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) -def test_rotation(region, mock_get_value, mock_property, region_type): +def test_rotation(region, get_value, property_, region_type): reg = region(region_type) if region_type == RT.ANNRULER: - mock_property(reg, "endpoints", [(90, 110), (110, 90)]) + property_(reg, "endpoints", [(90, 110), (110, 90)]) else: - mock_rotation = mock_get_value(reg, "dummy") + mock_rotation = get_value(reg, "dummy") value = reg.rotation @@ -570,15 +571,15 @@ def test_rotation(region, mock_get_value, mock_property, region_type): @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) -def test_set_rotation(region, mock_call_action, mock_method, mock_property, region_type): +def test_set_rotation(region, call_action, method, property_, region_type): reg = region(region_type) if region_type == RT.ANNRULER: - mock_property(reg, "center", (100, 100)) - mock_property(reg, "size", (20, 20)) - mock_set_points = mock_method(reg, "set_control_points", None) + property_(reg, "center", (100, 100)) + property_(reg, "size", (20, 20)) + mock_set_points = method(reg, "set_control_points", None) else: - mock_call = mock_call_action(reg) + mock_call = call_action(reg) reg.set_rotation(45) @@ -589,26 +590,26 @@ def test_set_rotation(region, mock_call_action, mock_method, mock_property, regi @pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) -def test_vertices(region, mock_property, region_type): +def test_vertices(region, property_, region_type): reg = region(region_type) - mock_property(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) + property_(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) vertices = reg.vertices assert vertices == [(10, 10), (20, 30), (30, 20)] @pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) -def test_wcs_vertices(region, mock_property, mock_to_world, region_type): +def test_wcs_vertices(region, property_, mock_to_world, region_type): reg = region(region_type) - mock_property(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) + property_(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) vertices = reg.wcs_vertices assert vertices == [("10", "10"), ("20", "30"), ("30", "20")] @pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) @pytest.mark.parametrize("vertex", [(30, 40), ("30", "40")]) -def test_set_vertex(region, mock_method, mock_from_world, region_type, vertex): +def test_set_vertex(region, method, mock_from_world, region_type, vertex): reg = region(region_type) - mock_set_control_point = mock_method(reg, "set_control_point", None) + mock_set_control_point = method(reg, "set_control_point", None) reg.set_vertex(1, vertex) mock_set_control_point.assert_called_with(1, (30, 40)) @@ -618,41 +619,41 @@ def test_set_vertex(region, mock_method, mock_from_world, region_type, vertex): [(10, 10), (20, 30), (30, 20)], [("10", "10"), ("20", "30"), ("30", "20")], ]) -def test_set_vertices(region, mock_method, mock_from_world, region_type, vertices): +def test_set_vertices(region, method, mock_from_world, region_type, vertices): reg = region(region_type) - mock_set_control_points = mock_method(reg, "set_control_points", None) + mock_set_control_points = method(reg, "set_control_points", None) reg.set_vertices(vertices) mock_set_control_points.assert_called_with([(10, 10), (20, 30), (30, 20)]) @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) -def test_endpoints(region, mock_property, region_type): +def test_endpoints(region, property_, region_type): reg = region(region_type) - mock_property(reg, "control_points", [(10, 10), (20, 30)]) + property_(reg, "control_points", [(10, 10), (20, 30)]) endpoints = reg.endpoints assert endpoints == [(10, 10), (20, 30)] @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) -def test_wcs_endpoints(region, mock_property, mock_to_world, region_type): +def test_wcs_endpoints(region, property_, mock_to_world, region_type): reg = region(region_type) - mock_property(reg, "control_points", [(10, 10), (20, 30)]) + property_(reg, "control_points", [(10, 10), (20, 30)]) endpoints = reg.wcs_endpoints assert endpoints == [("10", "10"), ("20", "30")] @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) -def test_length(region, mock_property, region_type): +def test_length(region, property_, region_type): reg = region(region_type) - mock_property(reg, "size", (30, 40)) + property_(reg, "size", (30, 40)) length = reg.length assert length == 50 @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) -def test_wcs_length(region, mock_property, region_type): +def test_wcs_length(region, property_, region_type): reg = region(region_type) - mock_property(reg, "wcs_size", ("30", "40")) + property_(reg, "wcs_size", ("30", "40")) length = reg.wcs_length assert length == "50\"" @@ -666,21 +667,21 @@ def test_wcs_length(region, mock_property, region_type): ([], {"start": (10, 10)}, [(0, (10, 10))]), ([], {"end": (20, 30)}, [(1, (20, 30))]), ]) -def test_set_endpoints(mocker, region, mock_method, mock_from_world, region_type, args, kwargs, expected_calls): +def test_set_endpoints(mocker, region, method, mock_from_world, region_type, args, kwargs, expected_calls): reg = region(region_type) - mock_set_control_point = mock_method(reg, "set_control_point", None) + mock_set_control_point = method(reg, "set_control_point", None) reg.set_endpoints(*args, **kwargs) mock_set_control_point.assert_has_calls([mocker.call(*c) for c in expected_calls]) @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) @pytest.mark.parametrize("length", [math.sqrt(800), str(math.sqrt(800))]) -def test_set_length(mocker, region, mock_property, region_type, length): +def test_set_length(mocker, region, property_, region_type, length): reg = region(region_type) - mock_property(reg, "length", 100) - mock_property(reg, "wcs_length", "100") - mock_property(reg, "rotation", 45) + property_(reg, "length", 100) + property_(reg, "wcs_length", "100") + property_(reg, "rotation", 45) mock_region_set_size = mocker.patch.object(Region, "set_size") reg.set_length(length) @@ -697,9 +698,9 @@ def test_set_length(mocker, region, mock_property, region_type, length): ("font_style", "fontStyle", "Bold", AFS.BOLD), ("font", "font", "Courier", AF.COURIER), ]) -def test_font_properties(region, mock_get_value, region_type, method_name, value_name, mocked_value, expected_value): +def test_font_properties(region, get_value, region_type, method_name, value_name, mocked_value, expected_value): reg = region(region_type) - mock_value_getter = mock_get_value(reg, mocked_value) + mock_value_getter = get_value(reg, mocked_value) value = getattr(reg, method_name) mock_value_getter.assert_called_with(value_name) assert value == expected_value @@ -713,9 +714,9 @@ def test_font_properties(region, mock_get_value, region_type, method_name, value ([AF.COURIER], {"font_style": AFS.BOLD}, [("setFont", AF.COURIER), ("setFontStyle", AFS.BOLD)]), ([], {"font_size": 20}, [("setFontSize", 20)]), ]) -def test_set_font(mocker, region, mock_call_action, region_type, args, kwargs, expected_calls): +def test_set_font(mocker, region, call_action, region_type, args, kwargs, expected_calls): reg = region(region_type) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_font(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -725,9 +726,9 @@ def test_set_font(mocker, region, mock_call_action, region_type, args, kwargs, e ("pointer_width", "pointerWidth"), ("pointer_length", "pointerLength"), ]) -def test_pointer_properties(region, mock_get_value, region_type, method_name, value_name): +def test_pointer_properties(region, get_value, region_type, method_name, value_name): reg = region(region_type) - mock_value_getter = mock_get_value(reg, "dummy") + mock_value_getter = get_value(reg, "dummy") value = getattr(reg, method_name) mock_value_getter.assert_called_with(value_name) assert value == "dummy" @@ -739,18 +740,18 @@ def test_pointer_properties(region, mock_get_value, region_type, method_name, va ([2, 20], {}, [("setPointerWidth", 2), ("setPointerLength", 20)]), ([], {"pointer_length": 20}, [("setPointerLength", 20)]), ]) -def test_set_pointer_style(mocker, region, mock_call_action, region_type, args, kwargs, expected_calls): +def test_set_pointer_style(mocker, region, call_action, region_type, args, kwargs, expected_calls): reg = region(region_type) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_pointer_style(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) @pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) -def test_corners(region, mock_property, region_type): +def test_corners(region, property_, region_type): reg = region(region_type) - mock_property(reg, "center", (100, 200)) - mock_property(reg, "size", (30, 40)) + property_(reg, "center", (100, 200)) + property_(reg, "size", (30, 40)) bottom_left, top_right = reg.corners @@ -759,9 +760,9 @@ def test_corners(region, mock_property, region_type): @pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) -def test_wcs_corners(region, mock_property, mock_to_world, region_type): +def test_wcs_corners(region, property_, mock_to_world, region_type): reg = region(region_type) - mock_property(reg, "corners", [(85, 180), (115, 220)]) + property_(reg, "corners", [(85, 180), (115, 220)]) bottom_left, top_right = reg.wcs_corners @@ -776,10 +777,10 @@ def test_wcs_corners(region, mock_property, mock_to_world, region_type): ([(75, 170)], {}, [(95.0, 195.0), (40, 50)]), ([], {"top_right": (135, 240)}, [(110.0, 210.0), (50, 60)]), ]) -def test_set_corners(region, mock_method, mock_property, mock_from_world, region_type, args, kwargs, expected_args): +def test_set_corners(region, method, property_, mock_from_world, region_type, args, kwargs, expected_args): reg = region(region_type) - mock_property(reg, "corners", [(85, 180), (115, 220)]) - mock_set_control_points = mock_method(reg, "set_control_points", None) + property_(reg, "corners", [(85, 180), (115, 220)]) + mock_set_control_points = method(reg, "set_control_points", None) reg.set_corners(*args, **kwargs) @@ -824,9 +825,9 @@ def test_set_semi_axes(mocker, region, mock_from_angular, region_type, semi_axes ("point_shape", "pointShape", 2, PS.CIRCLE), ("point_width", "pointWidth", 5, 5), ]) -def test_point_properties(region, mock_get_value, method_name, value_name, mocked_value, expected_value): +def test_point_properties(region, get_value, method_name, value_name, mocked_value, expected_value): reg = region(RT.ANNPOINT) - mock_value_getter = mock_get_value(reg, mocked_value) + mock_value_getter = get_value(reg, mocked_value) value = getattr(reg, method_name) mock_value_getter.assert_called_with(value_name) assert value == expected_value @@ -838,9 +839,9 @@ def test_point_properties(region, mock_get_value, method_name, value_name, mocke ([], {"point_shape": PS.CIRCLE}, [("setPointShape", PS.CIRCLE)]), ([], {"point_width": 5}, [("setPointWidth", 5)]), ]) -def test_set_point_style(mocker, region, mock_call_action, args, kwargs, expected_calls): +def test_set_point_style(mocker, region, call_action, args, kwargs, expected_calls): reg = region(RT.ANNPOINT) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_point_style(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -849,24 +850,24 @@ def test_set_point_style(mocker, region, mock_call_action, args, kwargs, expecte ("text", "text", "my text", "my text"), ("position", "position", 3, TP.LOWER_LEFT), ]) -def test_text_properties(region, mock_get_value, method_name, value_name, mocked_value, expected_value): +def test_text_properties(region, get_value, method_name, value_name, mocked_value, expected_value): reg = region(RT.ANNTEXT) - mock_value_getter = mock_get_value(reg, mocked_value) + mock_value_getter = get_value(reg, mocked_value) value = getattr(reg, method_name) mock_value_getter.assert_called_with(value_name) assert value == expected_value -def test_set_text(region, mock_call_action): +def test_set_text(region, call_action): reg = region(RT.ANNTEXT) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_text("my text") mock_action_caller.assert_called_with("setText", "my text") -def test_set_text_position(region, mock_call_action): +def test_set_text_position(region, call_action): reg = region(RT.ANNTEXT) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_text_position(TP.LOWER_LEFT) mock_action_caller.assert_called_with("setPosition", TP.LOWER_LEFT) @@ -891,9 +892,9 @@ def test_compass_properties(region, mocker, method_name, value_names, mocked_val (["N"], {}, [("setLabel", "N", True)]), ([], {"east_label": "E"}, [("setLabel", "E", False)]), ]) -def test_set_label(mocker, region, mock_call_action, args, kwargs, expected_calls): +def test_set_label(mocker, region, call_action, args, kwargs, expected_calls): reg = region(RT.ANNCOMPASS) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_label(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -904,10 +905,10 @@ def test_set_label(mocker, region, mock_call_action, args, kwargs, expected_call (["100"], {"spatial_axis": SA.X}, [("setLength", 100)], None), (["100"], {}, [], "Please specify a spatial axis"), ]) -def test_set_point_length(mocker, region, mock_call_action, mock_image_method, args, kwargs, expected_calls, error_contains): +def test_set_point_length(mocker, region, call_action, image_method, args, kwargs, expected_calls, error_contains): reg = region(RT.ANNCOMPASS) - mock_action_caller = mock_call_action(reg) - mock_image_method("from_angular_size", [100]) + mock_action_caller = call_action(reg) + image_method("from_angular_size", [100]) if error_contains is None: reg.set_point_length(*args, **kwargs) @@ -924,9 +925,9 @@ def test_set_point_length(mocker, region, mock_call_action, mock_image_method, a ([(1, 2)], {}, [("setNorthTextOffset", 1, True), ("setNorthTextOffset", 2, False)]), ([], {"east_offset": (3, 4)}, [("setEastTextOffset", 3, True), ("setEastTextOffset", 4, False)]), ]) -def test_set_label_offset(mocker, region, mock_call_action, args, kwargs, expected_calls): +def test_set_label_offset(mocker, region, call_action, args, kwargs, expected_calls): reg = region(RT.ANNCOMPASS) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_label_offset(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -937,9 +938,9 @@ def test_set_label_offset(mocker, region, mock_call_action, args, kwargs, expect ([True], {}, [("setNorthArrowhead", True)]), ([], {"east": False}, [("setEastArrowhead", False)]), ]) -def test_set_arrowhead_visible(mocker, region, mock_call_action, args, kwargs, expected_calls): +def test_set_arrowhead_visible(mocker, region, call_action, args, kwargs, expected_calls): reg = region(RT.ANNCOMPASS) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_arrowhead_visible(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -949,9 +950,9 @@ def test_set_arrowhead_visible(mocker, region, mock_call_action, args, kwargs, e ("auxiliary_lines_dash_length", "auxiliaryLineDashLength", 5, 5), ("text_offset", "textOffset", {"x": 1, "y": 2}, (1, 2)), ]) -def test_ruler_properties(region, mock_get_value, method_name, value_name, mocked_value, expected_value): +def test_ruler_properties(region, get_value, method_name, value_name, mocked_value, expected_value): reg = region(RT.ANNRULER) - mock_value_getter = mock_get_value(reg, mocked_value) + mock_value_getter = get_value(reg, mocked_value) value = getattr(reg, method_name) mock_value_getter.assert_called_with(value_name) assert value == expected_value @@ -963,9 +964,9 @@ def test_ruler_properties(region, mock_get_value, method_name, value_name, mocke ([True], {}, [("setAuxiliaryLineVisible", True)]), ([], {"dash_length": 5}, [("setAuxiliaryLineDashLength", 5)]), ]) -def test_set_auxiliary_lines_style(mocker, region, mock_call_action, args, kwargs, expected_calls): +def test_set_auxiliary_lines_style(mocker, region, call_action, args, kwargs, expected_calls): reg = region(RT.ANNRULER) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_auxiliary_lines_style(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -976,10 +977,8 @@ def test_set_auxiliary_lines_style(mocker, region, mock_call_action, args, kwarg ([1], {}, [("setTextOffset", 1, True)]), ([], {"offset_y": 2}, [("setTextOffset", 2, False)]), ]) -def test_set_text_offset(mocker, region, mock_call_action, args, kwargs, expected_calls): +def test_set_text_offset(mocker, region, call_action, args, kwargs, expected_calls): reg = region(RT.ANNRULER) - mock_action_caller = mock_call_action(reg) + mock_action_caller = call_action(reg) reg.set_text_offset(*args, **kwargs) mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) - -# TODO n.b. move default endpoint length method into mixin diff --git a/tests/test_session.py b/tests/test_session.py index e6a3503..4cec07d 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -19,19 +19,19 @@ def session(): @pytest.fixture -def mock_get_value(session, mocker): +def get_value(session, mocker): """Return a mock for session's get_value.""" return mocker.patch.object(session, "get_value") @pytest.fixture -def mock_call_action(session, mocker): +def call_action(session, mocker): """Return a mock for session's call_action.""" return mocker.patch.object(session, "call_action") @pytest.fixture -def mock_property(mocker): +def property_(mocker): """Return a helper function to mock the value of a decorated session property using a simple syntax.""" def func(property_name, mock_value): return mocker.patch(f"carta.session.Session.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) @@ -39,7 +39,7 @@ def func(property_name, mock_value): @pytest.fixture -def mock_method(session, mocker): +def method(session, mocker): """Return a helper function to mock the return value(s) of an session method using a simple syntax.""" def func(method_name, return_values): return mocker.patch.object(session, method_name, side_effect=return_values) @@ -82,31 +82,31 @@ def test_session_classmethods_have_docstrings(member): ("foo/..", "/current/dir"), ("foo/../bar", "/current/dir/bar"), ]) -def test_resolve_file_path(session, mock_method, path, expected_path): - mock_method("pwd", ["/current/dir"]) +def test_resolve_file_path(session, method, path, expected_path): + method("pwd", ["/current/dir"]) assert session.resolve_file_path(path) == expected_path -def test_pwd(session, mock_call_action, mock_get_value): - mock_get_value.side_effect = ["current/dir/"] +def test_pwd(session, call_action, get_value): + get_value.side_effect = ["current/dir/"] pwd = session.pwd() - mock_call_action.assert_called_with("fileBrowserStore.getFileList", Macro('fileBrowserStore', 'startingDirectory')) - mock_get_value.assert_called_with("fileBrowserStore.fileList.directory") + call_action.assert_called_with("fileBrowserStore.getFileList", Macro('fileBrowserStore', 'startingDirectory')) + get_value.assert_called_with("fileBrowserStore.fileList.directory") assert pwd == "/current/dir" -def test_ls(session, mock_method, mock_call_action): - mock_method("pwd", ["/current/dir"]) - mock_call_action.side_effect = [{"files": [{"name": "foo.fits"}, {"name": "bar.fits"}], "subdirectories": [{"name": "baz"}]}] +def test_ls(session, method, call_action): + method("pwd", ["/current/dir"]) + call_action.side_effect = [{"files": [{"name": "foo.fits"}, {"name": "bar.fits"}], "subdirectories": [{"name": "baz"}]}] ls = session.ls() - mock_call_action.assert_called_with("backendService.getFileList", "/current/dir", 2) + call_action.assert_called_with("backendService.getFileList", "/current/dir", 2) assert ls == ["bar.fits", "baz/", "foo.fits"] -def test_cd(session, mock_method, mock_call_action): - mock_method("resolve_file_path", ["/resolved/file/path"]) +def test_cd(session, method, call_action): + method("resolve_file_path", ["/resolved/file/path"]) session.cd("original/path") - mock_call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") + call_action.assert_called_with("fileBrowserStore.saveStartingDirectory", "/resolved/file/path") # OPENING IMAGES @@ -178,8 +178,8 @@ def test_open_LEL_image(mocker, session, args, kwargs, expected_args, expected_k @pytest.mark.parametrize("append", [True, False]) -def test_open_images(mocker, session, mock_method, append): - mock_open_image = mock_method("open_image", ["1", "2", "3"]) +def test_open_images(mocker, session, method, append): + mock_open_image = method("open_image", ["1", "2", "3"]) images = session.open_images(["foo.fits", "bar.fits", "baz.fits"], append) mock_open_image.assert_has_calls([ mocker.call("foo.fits", append=append), @@ -201,14 +201,14 @@ def test_open_images(mocker, session, mock_method, append): (True, "appendConcatFile"), (False, "openConcatFile"), ]) -def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mock_method, paths, expected_args, append, expected_command): - mock_method("pwd", ["/current/dir"]) - mock_method("resolve_file_path", ["/resolved/path"] * 3) - mock_call_action.side_effect = [*expected_args[0], 123] +def test_open_hypercube_guess_polarization(mocker, session, call_action, method, paths, expected_args, append, expected_command): + method("pwd", ["/current/dir"]) + method("resolve_file_path", ["/resolved/path"] * 3) + call_action.side_effect = [*expected_args[0], 123] hypercube = session.open_hypercube(paths, append) - mock_call_action.assert_has_calls([ + call_action.assert_has_calls([ mocker.call("fileBrowserStore.getStokesFile", "/resolved/path", "foo.fits", ""), mocker.call("fileBrowserStore.getStokesFile", "/resolved/path", "bar.fits", ""), mocker.call("fileBrowserStore.getStokesFile", "/resolved/path", "baz.fits", ""), @@ -234,16 +234,16 @@ def test_open_hypercube_guess_polarization(mocker, session, mock_call_action, mo {"directory": "/resolved/path", "file": "bar.fits", "hdu": "", "polarizationType": 1}, ], "Duplicate polarizations deduced"), ]) -def test_open_hypercube_guess_polarization_bad(mocker, session, mock_call_action, mock_method, paths, expected_calls, mocked_side_effect, expected_error): - mock_method("pwd", ["/current/dir"]) - mock_method("resolve_file_path", ["/resolved/path"] * 3) - mock_call_action.side_effect = mocked_side_effect +def test_open_hypercube_guess_polarization_bad(mocker, session, call_action, method, paths, expected_calls, mocked_side_effect, expected_error): + method("pwd", ["/current/dir"]) + method("resolve_file_path", ["/resolved/path"] * 3) + call_action.side_effect = mocked_side_effect with pytest.raises(ValueError) as e: session.open_hypercube(paths) assert expected_error in str(e.value) - mock_call_action.assert_has_calls([mocker.call(*args) for args in expected_calls]) + call_action.assert_has_calls([mocker.call(*args) for args in expected_calls]) @pytest.mark.parametrize("paths,expected_args", [ @@ -258,14 +258,14 @@ def test_open_hypercube_guess_polarization_bad(mocker, session, mock_call_action (True, "appendConcatFile"), (False, "openConcatFile"), ]) -def test_open_hypercube_explicit_polarization(mocker, session, mock_call_action, mock_method, paths, expected_args, append, expected_command): - mock_method("pwd", ["/current/dir"]) - mock_method("resolve_file_path", ["/resolved/path"] * 3) - mock_call_action.side_effect = [123] +def test_open_hypercube_explicit_polarization(mocker, session, call_action, method, paths, expected_args, append, expected_command): + method("pwd", ["/current/dir"]) + method("resolve_file_path", ["/resolved/path"] * 3) + call_action.side_effect = [123] hypercube = session.open_hypercube(paths, append) - mock_call_action.assert_has_calls([ + call_action.assert_has_calls([ mocker.call(expected_command, *expected_args), ]) @@ -279,9 +279,9 @@ def test_open_hypercube_explicit_polarization(mocker, session, mock_call_action, (["foo.fits"], "at least 2"), ]) @pytest.mark.parametrize("append", [True, False]) -def test_open_hypercube_bad(mocker, session, mock_call_action, mock_method, paths, expected_error, append): - mock_method("pwd", ["/current/dir"]) - mock_method("resolve_file_path", ["/resolved/path"] * 3) +def test_open_hypercube_bad(mocker, session, call_action, method, paths, expected_error, append): + method("pwd", ["/current/dir"]) + method("resolve_file_path", ["/resolved/path"] * 3) with pytest.raises(Exception) as e: session.open_hypercube(paths, append) @@ -292,9 +292,9 @@ def test_open_hypercube_bad(mocker, session, mock_call_action, mock_method, path @pytest.mark.parametrize("system", CoordinateSystem) -def test_set_coordinate_system(session, mock_call_action, system): +def test_set_coordinate_system(session, call_action, system): session.set_coordinate_system(system) - mock_call_action.assert_called_with("overlayStore.global.setSystem", system) + call_action.assert_called_with("overlayStore.global.setSystem", system) def test_set_coordinate_system_invalid(session): @@ -303,18 +303,18 @@ def test_set_coordinate_system_invalid(session): assert "Invalid function parameter" in str(e.value) -def test_coordinate_system(session, mock_get_value): - mock_get_value.return_value = "AUTO" +def test_coordinate_system(session, get_value): + get_value.return_value = "AUTO" system = session.coordinate_system() - mock_get_value.assert_called_with("overlayStore.global.system") + get_value.assert_called_with("overlayStore.global.system") assert isinstance(system, CoordinateSystem) @pytest.mark.parametrize("x", NF) @pytest.mark.parametrize("y", NF) -def test_set_custom_number_format(mocker, session, mock_call_action, x, y): +def test_set_custom_number_format(mocker, session, call_action, x, y): session.set_custom_number_format(x, y) - mock_call_action.assert_has_calls([ + call_action.assert_has_calls([ mocker.call("overlayStore.numbers.setFormatX", x), mocker.call("overlayStore.numbers.setFormatY", y), mocker.call("overlayStore.numbers.setCustomFormat", True), @@ -332,15 +332,15 @@ def test_set_custom_number_format_invalid(session, x, y): assert "Invalid function parameter" in str(e.value) -def test_clear_custom_number_format(session, mock_call_action): +def test_clear_custom_number_format(session, call_action): session.clear_custom_number_format() - mock_call_action.assert_called_with("overlayStore.numbers.setCustomFormat", False) + call_action.assert_called_with("overlayStore.numbers.setCustomFormat", False) -def test_number_format(session, mock_get_value, mocker): - mock_get_value.side_effect = [NF.DEGREES, NF.DEGREES, False] +def test_number_format(session, get_value, mocker): + get_value.side_effect = [NF.DEGREES, NF.DEGREES, False] x, y, _ = session.number_format() - mock_get_value.assert_has_calls([ + get_value.assert_has_calls([ mocker.call("overlayStore.numbers.formatTypeX"), mocker.call("overlayStore.numbers.formatTypeY"), mocker.call("overlayStore.numbers.customFormat"), From 67d0d96f05624a1385e4529b6ac3b34cbd9dfce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 27 Oct 2023 15:04:10 +0200 Subject: [PATCH 33/50] Fixed style issues found by updated style check. --- carta/region.py | 2 ++ carta/util.py | 1 + carta/validation.py | 10 +++++++++ tests/test_region.py | 51 -------------------------------------------- 4 files changed, 13 insertions(+), 51 deletions(-) diff --git a/carta/region.py b/carta/region.py index 16f7a0f..1eb0c7b 100644 --- a/carta/region.py +++ b/carta/region.py @@ -463,6 +463,7 @@ class Region(BasePathMixin): """Mapping of :obj:`carta.constants.RegionType` types to region and annotation classes. This mapping is used to select the appropriate subclass when a region or annotation object is constructed in the wrapper.""" def __init_subclass__(cls, **kwargs): + """Automatically register subclasses in mapping from region types to classes.""" super().__init_subclass__(**kwargs) for t in cls.REGION_TYPES: @@ -477,6 +478,7 @@ def __init__(self, region_set, region_id): self._region = Macro("", self._base_path) def __repr__(self): + """A human-readable representation of this region.""" return f"{self.region_id}:{self.region_type.label}" # CREATE OR CONNECT diff --git a/carta/util.py b/carta/util.py index c6528c6..0e9e615 100644 --- a/carta/util.py +++ b/carta/util.py @@ -233,6 +233,7 @@ def __init__(self, x, y): self.y = y def __eq__(self, other): + """Check for equality by comparing x and y attributes.""" return hasattr(other, "x") and hasattr(other, "y") and self.x == other.x and self.y == other.y @classmethod diff --git a/carta/validation.py b/carta/validation.py index 8bdd36a..04a8613 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -737,6 +737,8 @@ class Point(Union): """ class NumericPoint(Union): + """A pair of numbers.""" + def __init__(self): options = ( IterableOf(Number(), min_size=2, max_size=2), @@ -744,6 +746,8 @@ def __init__(self): super().__init__(*options, description="a pair of numbers") class WorldCoordinatePoint(Union): + """A pair of WCS coordinate strings.""" + def __init__(self): options = ( IterableOf(Coordinate.WorldCoordinate(), min_size=2, max_size=2), @@ -751,6 +755,8 @@ def __init__(self): super().__init__(*options, description="a pair of coordinate strings") class AngularSizePoint(Union): + """A pair of angular size strings.""" + def __init__(self): options = ( IterableOf(Size.AngularSize(), min_size=2, max_size=2), @@ -758,6 +764,8 @@ def __init__(self): super().__init__(*options, description="a pair of size strings") class CoordinatePoint(Union): + """A pair of coordinates.""" + def __init__(self): options = ( Point.NumericPoint(), @@ -766,6 +774,8 @@ def __init__(self): super().__init__(*options, description="a pair of numbers or coordinate strings") class SizePoint(Union): + """A pair of angular sizes.""" + def __init__(self): options = ( Point.NumericPoint(), diff --git a/tests/test_region.py b/tests/test_region.py index aeb71fd..b9bbb38 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -1,10 +1,8 @@ import pytest -import inspect import math from carta.session import Session from carta.image import Image -import carta.region # For docstring inspection from carta.region import Region from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS, TextPosition as TP, SpatialAxis as SA from carta.util import Point as Pt, Macro @@ -145,54 +143,6 @@ def func(region, method_name, return_values): # TESTS -# DOCSTRINGS - - -def find_classes(): - for name, clazz in inspect.getmembers(carta.region, inspect.isclass): - if not clazz.__module__ == 'carta.region': - continue - yield clazz - - -def find_methods(classes): - for clazz in classes: - for name, member in inspect.getmembers(clazz, lambda m: inspect.isfunction(m) or inspect.ismethod(m)): - if not member.__module__ == 'carta.region': - continue - if not member.__qualname__.split('.')[0] == clazz.__name__: - continue - if member.__name__.startswith('__'): - continue - yield member - - -def find_properties(classes): - for clazz in classes: - for name, member in inspect.getmembers(clazz, lambda m: isinstance(m, property)): - if not member.fget.__module__ == 'carta.region': - continue - if not member.fget.__qualname__.split('.')[0] == clazz.__name__: - continue - if member.fget.__name__.startswith('__'): - continue - yield member.fget - - -@pytest.mark.parametrize("member", find_classes()) -def test_region_classes_have_docstrings(member): - assert member.__doc__ is not None - - -@pytest.mark.parametrize("member", find_methods(find_classes())) -def test_region_methods_have_docstrings(member): - assert member.__doc__ is not None - - -@pytest.mark.parametrize("member", find_properties(find_classes())) -def test_region_properties_have_docstrings(member): - assert member.__doc__ is not None - # REGION SET @@ -466,7 +416,6 @@ def test_set_size(region, mock_from_angular, call_action, method, property_, reg reg.set_size(value) if region_type == RT.ANNRULER: - #mock_set_points.assert_called_with([(20.0, 25.0), (0.0, -5.0)]) mock_set_points.assert_called_with([(0.0, -5.0), (20.0, 25.0)]) elif region_type == RT.ANNCOMPASS: mock_call.assert_called_with("setLength", min(expected_value.x, expected_value.y)) From 8a5f4f1e6a882bd2b3fc55ca23ec10fd27b1c1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 27 Oct 2023 16:14:13 +0200 Subject: [PATCH 34/50] Refactored shared fixtures --- tests/test_region.py | 145 ++++++++++++++++--------------------------- 1 file changed, 54 insertions(+), 91 deletions(-) diff --git a/tests/test_region.py b/tests/test_region.py index b9bbb38..36dfe04 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -1,8 +1,6 @@ import pytest import math -from carta.session import Session -from carta.image import Image from carta.region import Region from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS, TextPosition as TP, SpatialAxis as SA from carta.util import Point as Pt, Macro @@ -13,41 +11,18 @@ @pytest.fixture -def session(): - """Return a session object. - - The session's protocol is set to None, so any tests that use this must also mock the session's call_action and/or higher-level functions which call it. - """ - return Session(0, None) - - -@pytest.fixture -def image(session): - """Return an image object which uses the session fixture. - """ - return Image(session, 0) - - -@pytest.fixture -def session_call_action(session, mocker): - """Return a mock for session's call_action.""" - return mocker.patch.object(session, "call_action") +def session_call_action(session, mock_call_action): + return mock_call_action(session) @pytest.fixture -def session_method(session, mocker): - """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" - def func(method_name, return_values): - return mocker.patch.object(session, method_name, side_effect=return_values) - return func +def session_method(session, mock_method): + return mock_method(session) @pytest.fixture -def image_method(image, mocker): - """Return a helper function to mock the return value(s) of an image method using a simple syntax.""" - def func(method_name, return_values): - return mocker.patch.object(image, method_name, side_effect=return_values) - return func +def image_method(image, mock_method): + return mock_method(image) @pytest.fixture @@ -64,23 +39,18 @@ def mock_to_angular(image_method): @pytest.fixture -def regionset_get_value(image, mocker): - """Return a mock for image.regions' get_value.""" - return mocker.patch.object(image.regions, "get_value") +def regionset_get_value(image, mock_get_value): + return mock_get_value(image.regions) @pytest.fixture -def regionset_call_action(image, mocker): - """Return a mock for image.regions' call_action.""" - return mocker.patch.object(image.regions, "call_action") +def regionset_call_action(image, mock_call_action): + return mock_call_action(image.regions) @pytest.fixture -def regionset_method(image, mocker): - """Return a helper function to mock the return value(s) of a session method using a simple syntax.""" - def func(method_name, return_values): - return mocker.patch.object(image.regions, method_name, side_effect=return_values) - return func +def regionset_method(image, mock_method): + return mock_method(image.regions) @pytest.fixture @@ -97,12 +67,10 @@ def mock_from_angular(regionset_method): @pytest.fixture -def region(mocker, image): - """Return a factory for a new region object which uses the image fixture, specifying a class and/or ID. - """ +def region(image, mock_property): def func(region_type=None, region_id=0): clazz = Region if region_type is None else Region.region_class(region_type) - mocker.patch(f"carta.region.{clazz.__name__}.region_type", new_callable=mocker.PropertyMock, return_value=region_type) + mock_property(f"carta.region.{clazz.__name__}")("region_type", region_type) reg = clazz(image.regions, region_id) return reg return func @@ -110,34 +78,29 @@ def func(region_type=None, region_id=0): @pytest.fixture def get_value(mocker): - """Return a factory for a mock for a region object's get_value.""" - def func(region, mock_value=None): - return mocker.patch.object(region, "get_value", return_value=mock_value) + def func(reg, mock_value=None): + return mocker.patch.object(reg, "get_value", return_value=mock_value) return func @pytest.fixture -def call_action(mocker): - """Return a factory for a mock for a region object's call_action.""" - def func(region): - return mocker.patch.object(region, "call_action") +def call_action(mock_call_action): + def func(reg): + return mock_call_action(reg) return func @pytest.fixture -def property_(mocker): - """Return a factory for a mock for a region object's decorated property.""" - def func(region, property_name, mock_value=None): - class_name = region.__class__.__name__ # Is this going to work? Do we need to import it? - return mocker.patch(f"carta.region.{class_name}.{property_name}", new_callable=mocker.PropertyMock, return_value=mock_value) +def property_(mock_property): + def func(reg): + return mock_property(f"carta.region.{reg.__class__.__name__}") return func @pytest.fixture -def method(mocker): - """Return a factory for a mock for a region object's method.""" - def func(region, method_name, return_values): - return mocker.patch.object(region, method_name, side_effect=return_values) +def method(mock_method): + def func(reg): + return mock_method(reg) return func @@ -266,7 +229,7 @@ def test_regionset_add_region_with_type(mocker, image, regionset_method, mock_fr def test_regionset_clear(mocker, image, regionset_method, method, region): regionlist = [region(), region(), region()] - mock_deletes = [method(r, "delete", None) for r in regionlist] + mock_deletes = [method(r)("delete", None) for r in regionlist] regionset_method("list", [regionlist]) image.regions.clear() @@ -299,7 +262,7 @@ def test_center(region, get_value, region_type): @pytest.mark.parametrize("region_type", [t for t in RT]) def test_wcs_center(region, property_, mock_to_world, region_type): reg = region(region_type) - property_(reg, "center", (20, 30)) + property_(reg)("center", (20, 30)) wcs_center = reg.wcs_center @@ -335,7 +298,7 @@ def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): if region_type in {RT.ELLIPSE, RT.ANNELLIPSE, RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: # Bypasses wcsSize to call own (overridden) size and converts to angular units - property_(reg, "size", (20, 30)) + property_(reg)("size", (20, 30)) elif region_type in {RT.POINT, RT.ANNPOINT}: # Simulate undefined size reg_get_value = get_value(reg, {"x": None, "y": None}) @@ -385,9 +348,9 @@ def test_set_center(region, mock_from_world, call_action, method, property_, reg reg = region(region_type) if region_type == RT.ANNRULER: - property_(reg, "size", (-10, -10)) - property_(reg, "rotation", 135) - mock_set_points = method(reg, "set_control_points", None) + property_(reg)("size", (-10, -10)) + property_(reg)("rotation", 135) + mock_set_points = method(reg)("set_control_points", None) else: mock_call = call_action(reg) @@ -408,8 +371,8 @@ def test_set_size(region, mock_from_angular, call_action, method, property_, reg reg = region(region_type) if region_type == RT.ANNRULER: - mock_set_points = method(reg, "set_control_points", None) - property_(reg, "center", (10, 10)) + mock_set_points = method(reg)("set_control_points", None) + property_(reg)("center", (10, 10)) else: mock_call = call_action(reg) @@ -506,7 +469,7 @@ def test_rotation(region, get_value, property_, region_type): reg = region(region_type) if region_type == RT.ANNRULER: - property_(reg, "endpoints", [(90, 110), (110, 90)]) + property_(reg)("endpoints", [(90, 110), (110, 90)]) else: mock_rotation = get_value(reg, "dummy") @@ -524,9 +487,9 @@ def test_set_rotation(region, call_action, method, property_, region_type): reg = region(region_type) if region_type == RT.ANNRULER: - property_(reg, "center", (100, 100)) - property_(reg, "size", (20, 20)) - mock_set_points = method(reg, "set_control_points", None) + property_(reg)("center", (100, 100)) + property_(reg)("size", (20, 20)) + mock_set_points = method(reg)("set_control_points", None) else: mock_call = call_action(reg) @@ -541,7 +504,7 @@ def test_set_rotation(region, call_action, method, property_, region_type): @pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) def test_vertices(region, property_, region_type): reg = region(region_type) - property_(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) + property_(reg)("control_points", [(10, 10), (20, 30), (30, 20)]) vertices = reg.vertices assert vertices == [(10, 10), (20, 30), (30, 20)] @@ -549,7 +512,7 @@ def test_vertices(region, property_, region_type): @pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) def test_wcs_vertices(region, property_, mock_to_world, region_type): reg = region(region_type) - property_(reg, "control_points", [(10, 10), (20, 30), (30, 20)]) + property_(reg)("control_points", [(10, 10), (20, 30), (30, 20)]) vertices = reg.wcs_vertices assert vertices == [("10", "10"), ("20", "30"), ("30", "20")] @@ -558,7 +521,7 @@ def test_wcs_vertices(region, property_, mock_to_world, region_type): @pytest.mark.parametrize("vertex", [(30, 40), ("30", "40")]) def test_set_vertex(region, method, mock_from_world, region_type, vertex): reg = region(region_type) - mock_set_control_point = method(reg, "set_control_point", None) + mock_set_control_point = method(reg)("set_control_point", None) reg.set_vertex(1, vertex) mock_set_control_point.assert_called_with(1, (30, 40)) @@ -570,7 +533,7 @@ def test_set_vertex(region, method, mock_from_world, region_type, vertex): ]) def test_set_vertices(region, method, mock_from_world, region_type, vertices): reg = region(region_type) - mock_set_control_points = method(reg, "set_control_points", None) + mock_set_control_points = method(reg)("set_control_points", None) reg.set_vertices(vertices) mock_set_control_points.assert_called_with([(10, 10), (20, 30), (30, 20)]) @@ -578,7 +541,7 @@ def test_set_vertices(region, method, mock_from_world, region_type, vertices): @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) def test_endpoints(region, property_, region_type): reg = region(region_type) - property_(reg, "control_points", [(10, 10), (20, 30)]) + property_(reg)("control_points", [(10, 10), (20, 30)]) endpoints = reg.endpoints assert endpoints == [(10, 10), (20, 30)] @@ -586,7 +549,7 @@ def test_endpoints(region, property_, region_type): @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) def test_wcs_endpoints(region, property_, mock_to_world, region_type): reg = region(region_type) - property_(reg, "control_points", [(10, 10), (20, 30)]) + property_(reg)("control_points", [(10, 10), (20, 30)]) endpoints = reg.wcs_endpoints assert endpoints == [("10", "10"), ("20", "30")] @@ -594,7 +557,7 @@ def test_wcs_endpoints(region, property_, mock_to_world, region_type): @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) def test_length(region, property_, region_type): reg = region(region_type) - property_(reg, "size", (30, 40)) + property_(reg)("size", (30, 40)) length = reg.length assert length == 50 @@ -602,7 +565,7 @@ def test_length(region, property_, region_type): @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) def test_wcs_length(region, property_, region_type): reg = region(region_type) - property_(reg, "wcs_size", ("30", "40")) + property_(reg)("wcs_size", ("30", "40")) length = reg.wcs_length assert length == "50\"" @@ -618,7 +581,7 @@ def test_wcs_length(region, property_, region_type): ]) def test_set_endpoints(mocker, region, method, mock_from_world, region_type, args, kwargs, expected_calls): reg = region(region_type) - mock_set_control_point = method(reg, "set_control_point", None) + mock_set_control_point = method(reg)("set_control_point", None) reg.set_endpoints(*args, **kwargs) mock_set_control_point.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -628,9 +591,9 @@ def test_set_endpoints(mocker, region, method, mock_from_world, region_type, arg def test_set_length(mocker, region, property_, region_type, length): reg = region(region_type) - property_(reg, "length", 100) - property_(reg, "wcs_length", "100") - property_(reg, "rotation", 45) + property_(reg)("length", 100) + property_(reg)("wcs_length", "100") + property_(reg)("rotation", 45) mock_region_set_size = mocker.patch.object(Region, "set_size") reg.set_length(length) @@ -699,8 +662,8 @@ def test_set_pointer_style(mocker, region, call_action, region_type, args, kwarg @pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) def test_corners(region, property_, region_type): reg = region(region_type) - property_(reg, "center", (100, 200)) - property_(reg, "size", (30, 40)) + property_(reg)("center", (100, 200)) + property_(reg)("size", (30, 40)) bottom_left, top_right = reg.corners @@ -711,7 +674,7 @@ def test_corners(region, property_, region_type): @pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) def test_wcs_corners(region, property_, mock_to_world, region_type): reg = region(region_type) - property_(reg, "corners", [(85, 180), (115, 220)]) + property_(reg)("corners", [(85, 180), (115, 220)]) bottom_left, top_right = reg.wcs_corners @@ -728,8 +691,8 @@ def test_wcs_corners(region, property_, mock_to_world, region_type): ]) def test_set_corners(region, method, property_, mock_from_world, region_type, args, kwargs, expected_args): reg = region(region_type) - property_(reg, "corners", [(85, 180), (115, 220)]) - mock_set_control_points = method(reg, "set_control_points", None) + property_(reg)("corners", [(85, 180), (115, 220)]) + mock_set_control_points = method(reg)("set_control_points", None) reg.set_corners(*args, **kwargs) From 4bba19915880ebf6e48126b36759842581ad29f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 27 Oct 2023 16:26:54 +0200 Subject: [PATCH 35/50] Add regions to subobject test --- tests/test_image.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_image.py b/tests/test_image.py index 53b1b4d..32b67df 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -80,6 +80,7 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte @pytest.mark.parametrize("name,classname", [ ("vectors", "VectorOverlay"), + ("regions", "RegionSet"), ]) def test_subobjects(image, name, classname): assert getattr(image, name).__class__.__name__ == classname From a24f7abf0f186ee75fb4a242352864670db09816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 7 Dec 2023 12:52:03 +0200 Subject: [PATCH 36/50] Updated patched line-like region functionality to match latest dev frontend changes. --- carta/region.py | 52 ++++++++++++++++++++++++-------------------- tests/test_region.py | 11 ++++++---- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/carta/region.py b/carta/region.py index 1eb0c7b..bc41f1a 100644 --- a/carta/region.py +++ b/carta/region.py @@ -934,8 +934,8 @@ def size(self): The height. """ sx, sy = super().size - # negate the raw frontend value for consistency - return (-sx, -sy) + # return the magnitudes of the raw frontend values for consistency + return (abs(sx), abs(sy)) @property def wcs_size(self): @@ -1000,6 +1000,33 @@ def wcs_length(self): # SET PROPERTIES + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. Signs will be ignored; the orientation of the line will be preserved (unless one of the dimensions is set to zero). + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [size] = self.region_set._from_angular_sizes([size]) + + x, y = size + cx, cy = self.center + rad = math.radians(self.rotation) + + x = abs(x) + y = abs(y) + dx = math.copysign(x, math.sin(rad) or 1) + dy = -math.copysign(y, math.cos(rad) or 1) + + start = cx - dx / 2, cy - dy / 2 + end = cx + dx / 2, cy + dy / 2 + + self.set_control_points([start, end]) + @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) def set_endpoints(self, start=None, end=None): """Update the endpoints. @@ -1722,27 +1749,6 @@ def set_rotation(self, rotation): self.set_control_points([start, end]) - @validate(Point.SizePoint()) - def set_size(self, size): - """Set the size. - - Both pixel and angular sizes are accepted, but both values must match. - - Parameters - ---------- - size : {0} - The new width and height, in that order. - """ - [size] = self.region_set._from_angular_sizes([size]) - - cx, cy = self.center - dx, dy = size - - start = cx - dx / 2, cy - dy / 2 - end = cx + dx / 2, cy + dy / 2 - - self.set_control_points([start, end]) - @validate(*all_optional(Boolean(), Number())) def set_auxiliary_lines_style(self, visible=None, dash_length=None): """Set the auxiliary line style. diff --git a/tests/test_region.py b/tests/test_region.py index 36dfe04..3dfd95a 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -276,6 +276,9 @@ def test_size(region, get_value, region_type): if region_type in {RT.POINT, RT.ANNPOINT}: reg_get_value = get_value(reg, None) + elif region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: + # Check that we get the absolute values of these + reg_get_value = get_value(reg, {"x": -20, "y": -30}) else: reg_get_value = get_value(reg, {"x": 20, "y": 30}) @@ -286,8 +289,6 @@ def test_size(region, get_value, region_type): assert size == (60, 40) # The frontend size returned for an ellipse is the semi-axes, which we double and swap elif region_type in {RT.POINT, RT.ANNPOINT}: assert size is None # Test that returned null/undefined size for a point is converted to None as expected - elif region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: - assert size == (-20, -30) # Negate size returned by the frontend for endpoint regions else: assert size == (20, 30) @@ -365,20 +366,22 @@ def test_set_center(region, mock_from_world, call_action, method, property_, reg @pytest.mark.parametrize("region_type", [t for t in RT]) @pytest.mark.parametrize("value,expected_value", [ ((20, 30), Pt(20, 30)), + ((-20, -30), Pt(-20, -30)), (("20", "30"), Pt(20, 30)), ]) def test_set_size(region, mock_from_angular, call_action, method, property_, region_type, value, expected_value): reg = region(region_type) - if region_type == RT.ANNRULER: + if region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: mock_set_points = method(reg)("set_control_points", None) property_(reg)("center", (10, 10)) + property_(reg)("rotation", 135) else: mock_call = call_action(reg) reg.set_size(value) - if region_type == RT.ANNRULER: + if region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: mock_set_points.assert_called_with([(0.0, -5.0), (20.0, 25.0)]) elif region_type == RT.ANNCOMPASS: mock_call.assert_called_with("setLength", min(expected_value.x, expected_value.y)) From 4b7bc5a7b1cf5074a6aac6c43b2e13d9cbd16443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 18 Jun 2024 15:02:40 +0200 Subject: [PATCH 37/50] formatting pass and removal of unneeded import --- carta/image.py | 3 +-- carta/session.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/carta/image.py b/carta/image.py index 6df24ac..d80c696 100644 --- a/carta/image.py +++ b/carta/image.py @@ -7,7 +7,7 @@ from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit 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, all_optional, Point +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 @@ -61,7 +61,6 @@ def __init__(self, session, image_id): self.wcs = ImageWCSOverlay(self) self.regions = RegionSet(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/session.py b/carta/session.py index 423c0bb..fc0ddc8 100644 --- a/carta/session.py +++ b/carta/session.py @@ -20,6 +20,7 @@ from .raster import SessionRaster from .preferences import Preferences + class Session: """This object corresponds to a CARTA frontend session. From 8dde217c99496aa76ba70130bedcabac5b5bcc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 7 Oct 2024 11:50:11 +0800 Subject: [PATCH 38/50] fixing docs requirements --- docs/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 99eff4e..2952a33 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ -Sphinx==7.3.7 +Sphinx>=7 sphinx-rtd-theme==2.0.0 sphinxcontrib-napoleon==0.7 -simplejson==3.17.6 +simplejson>=3.17.6 sphinx-tabs==3.4.5 From 60255faadce3fd76d732f546dd8e393193046db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Thu, 30 Oct 2025 10:03:51 +0200 Subject: [PATCH 39/50] Removed custom workarounds for issues now fixed in the frontend. Still needs end-to-end testing. --- carta/region.py | 118 +------------------------------------------ tests/test_region.py | 73 ++++++-------------------- 2 files changed, 16 insertions(+), 175 deletions(-) diff --git a/carta/region.py b/carta/region.py index bc41f1a..1c387eb 100644 --- a/carta/region.py +++ b/carta/region.py @@ -922,35 +922,6 @@ class HasEndpointsMixin: # GET PROPERTIES - @property - def size(self): - """The size, in pixels. - - Returns - ------- - number - The width. - number - The height. - """ - sx, sy = super().size - # return the magnitudes of the raw frontend values for consistency - return (abs(sx), abs(sy)) - - @property - def wcs_size(self): - """The size, in angular size units. - - Returns - ------- - string - The width. - string - The height. - """ - [size] = self.region_set.image.to_angular_size_points([self.size]) - return size - @property def endpoints(self): """The endpoints, in image coordinates. @@ -1000,33 +971,6 @@ def wcs_length(self): # SET PROPERTIES - @validate(Point.SizePoint()) - def set_size(self, size): - """Set the size. - - Both pixel and angular sizes are accepted, but both values must match. Signs will be ignored; the orientation of the line will be preserved (unless one of the dimensions is set to zero). - - Parameters - ---------- - size : {0} - The new width and height, in that order. - """ - [size] = self.region_set._from_angular_sizes([size]) - - x, y = size - cx, cy = self.center - rad = math.radians(self.rotation) - - x = abs(x) - y = abs(y) - dx = math.copysign(x, math.sin(rad) or 1) - dy = -math.copysign(y, math.cos(rad) or 1) - - start = cx - dx / 2, cy - dy / 2 - end = cx + dx / 2, cy + dy / 2 - - self.set_control_points([start, end]) - @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) def set_endpoints(self, start=None, end=None): """Update the endpoints. @@ -1645,7 +1589,7 @@ def set_arrowhead_visible(self, north=None, east=None): self.call_action("setEastArrowhead", east) -class RulerAnnotation(HasFontMixin, HasEndpointsMixin, Region): +class RulerAnnotation(HasFontMixin, HasEndpointsMixin, HasRotationMixin, Region): """A ruler annotation.""" REGION_TYPES = (RegionType.ANNRULER,) """The region types corresponding to this class.""" @@ -1687,68 +1631,8 @@ def text_offset(self): """ return Pt(**self.get_value("textOffset")).as_tuple() - @property - def rotation(self): - """The rotation, in degrees. - - Returns - ------- - number - The rotation. - """ - ((sx, sy), (ex, ey)) = self.endpoints - rad = math.atan2(ex - sx, sy - ey) - rotation = math.degrees(rad) - rotation = (rotation + 360) % 360 - return rotation - # SET PROPERTIES - @validate(Point.CoordinatePoint()) - def set_center(self, center): - """Set the center position. - - Both image and world coordinates are accepted, but both values must match. - - Parameters - ---------- - center : {0} - The new center position. - """ - [center] = self.region_set._from_world_coordinates([center]) - cx, cy = center - - rad = math.radians(self.rotation) - dx = math.hypot(*self.size) * math.sin(rad) - dy = math.hypot(*self.size) * -1 * math.cos(rad) - - start = cx - dx / 2, cy - dy / 2 - end = cx + dx / 2, cy + dy / 2 - - self.set_control_points([start, end]) - - @validate(Number()) - def set_rotation(self, rotation): - """Set the rotation. - - Parameters - ---------- - angle : {0} - The new rotation, in degrees. - """ - rotation = rotation + 360 % 360 - - cx, cy = self.center - - rad = math.radians(rotation) - dx = math.hypot(*self.size) * math.sin(rad) - dy = math.hypot(*self.size) * -1 * math.cos(rad) - - start = cx - dx / 2, cy - dy / 2 - end = cx + dx / 2, cy + dy / 2 - - self.set_control_points([start, end]) - @validate(*all_optional(Boolean(), Number())) def set_auxiliary_lines_style(self, visible=None, dash_length=None): """Set the auxiliary line style. diff --git a/tests/test_region.py b/tests/test_region.py index 3dfd95a..a68dbe0 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -276,9 +276,6 @@ def test_size(region, get_value, region_type): if region_type in {RT.POINT, RT.ANNPOINT}: reg_get_value = get_value(reg, None) - elif region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: - # Check that we get the absolute values of these - reg_get_value = get_value(reg, {"x": -20, "y": -30}) else: reg_get_value = get_value(reg, {"x": 20, "y": 30}) @@ -297,7 +294,7 @@ def test_size(region, get_value, region_type): def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): reg = region(region_type) - if region_type in {RT.ELLIPSE, RT.ANNELLIPSE, RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: # Bypasses wcsSize to call own (overridden) size and converts to angular units property_(reg)("size", (20, 30)) elif region_type in {RT.POINT, RT.ANNPOINT}: @@ -308,7 +305,7 @@ def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): size = reg.wcs_size - if region_type in {RT.ELLIPSE, RT.ANNELLIPSE, RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: mock_to_angular.assert_called_with([(20, 30)]) assert size == ("20", "30") elif region_type in {RT.POINT, RT.ANNPOINT}: @@ -345,22 +342,11 @@ def test_simple_properties(region, get_value, method_name, value_name): ((20, 30), Pt(20, 30)), (("20", "30"), Pt(20, 30)), ]) -def test_set_center(region, mock_from_world, call_action, method, property_, region_type, value, expected_value): +def test_set_center(region, mock_from_world, call_action, region_type, value, expected_value): reg = region(region_type) - - if region_type == RT.ANNRULER: - property_(reg)("size", (-10, -10)) - property_(reg)("rotation", 135) - mock_set_points = method(reg)("set_control_points", None) - else: - mock_call = call_action(reg) - + mock_call = call_action(reg) reg.set_center(value) - - if region_type == RT.ANNRULER: - mock_set_points.assert_called_with([(15, 25), (25, 35)]) - else: - mock_call.assert_called_with("setCenter", expected_value) + mock_call.assert_called_with("setCenter", expected_value) @pytest.mark.parametrize("region_type", [t for t in RT]) @@ -369,21 +355,12 @@ def test_set_center(region, mock_from_world, call_action, method, property_, reg ((-20, -30), Pt(-20, -30)), (("20", "30"), Pt(20, 30)), ]) -def test_set_size(region, mock_from_angular, call_action, method, property_, region_type, value, expected_value): +def test_set_size(region, mock_from_angular, call_action, region_type, value, expected_value): reg = region(region_type) - - if region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: - mock_set_points = method(reg)("set_control_points", None) - property_(reg)("center", (10, 10)) - property_(reg)("rotation", 135) - else: - mock_call = call_action(reg) - + mock_call = call_action(reg) reg.set_size(value) - if region_type in {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}: - mock_set_points.assert_called_with([(0.0, -5.0), (20.0, 25.0)]) - elif region_type == RT.ANNCOMPASS: + if region_type == RT.ANNCOMPASS: mock_call.assert_called_with("setLength", min(expected_value.x, expected_value.y)) elif region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: mock_call.assert_called_with("setSize", Pt(expected_value.y / 2, expected_value.x / 2)) @@ -468,40 +445,20 @@ def test_delete(region, regionset_call_action): @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) -def test_rotation(region, get_value, property_, region_type): +def test_rotation(region, get_value, region_type): reg = region(region_type) - - if region_type == RT.ANNRULER: - property_(reg)("endpoints", [(90, 110), (110, 90)]) - else: - mock_rotation = get_value(reg, "dummy") - + mock_rotation = get_value(reg, "dummy") value = reg.rotation - - if region_type == RT.ANNRULER: - assert value == 45 - else: - mock_rotation.assert_called_with("rotation") - assert value == "dummy" + mock_rotation.assert_called_with("rotation") + assert value == "dummy" @pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) -def test_set_rotation(region, call_action, method, property_, region_type): +def test_set_rotation(region, call_action, region_type): reg = region(region_type) - - if region_type == RT.ANNRULER: - property_(reg)("center", (100, 100)) - property_(reg)("size", (20, 20)) - mock_set_points = method(reg)("set_control_points", None) - else: - mock_call = call_action(reg) - + mock_call = call_action(reg) reg.set_rotation(45) - - if region_type == RT.ANNRULER: - mock_set_points.assert_called_with([(90, 110), (110, 90)]) - else: - mock_call.assert_called_with("setRotation", 45) + mock_call.assert_called_with("setRotation", 45) @pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) From 7b9ae2d202dd319392b946e525415b64f7842991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 31 Oct 2025 15:41:53 +0200 Subject: [PATCH 40/50] added alternative functions for creating new rectangles and ellipses --- carta/region.py | 132 +++++++++++++++++++++++++++++++++++++++---- tests/test_region.py | 54 +++++++++++++----- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/carta/region.py b/carta/region.py index 1c387eb..b0889b0 100644 --- a/carta/region.py +++ b/carta/region.py @@ -193,6 +193,31 @@ def _from_angular_sizes(self, points): pass return points + def _center_size_from_corners(self, bottom_left, top_right): + """Internal utility function for calculating a center point and a size from a bottom-left and top-right corner. + + The corner points provided must be in image coordinates, and the returned center and size values are in image coordinates and pixel sizes, respectively. + + Parameters + ---------- + bottom_left : point in image coordinates + The bottom-left corner. + top_right : point in image coordinates + The top-right corner. + + Returns + ------- + point in image coordinates + The center point. + pair of pixel sizes + The size. + """ + bottom_left = Pt(*bottom_left) + top_right = Pt(*top_right) + size = Pt(top_right.x - bottom_left.x, top_right.y - bottom_left.y) + center = size.x / 2 + bottom_left.x, size.y / 2 + bottom_left.y + return center, size.as_tuple() + @validate(Point.CoordinatePoint(), Boolean(), String()) def add_point(self, center, annotation=False, name=""): """Add a new point region or point annotation to this image. @@ -215,9 +240,9 @@ def add_point(self, center, annotation=False, name=""): region_type = RegionType.ANNPOINT if annotation else RegionType.POINT return self.add_region(region_type, [center], name=name) - @validate(Point.CoordinatePoint(), Point.SizePoint(), Boolean(), Number(), String()) - def add_rectangle(self, center, size, annotation=False, rotation=0, name=""): - """Add a new rectangular region or rectangular annotation to this image. + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_rectangle(self, center, size, rotation=0, annotation=False, name=""): + """Add a new rectangular region or rectangular annotation to this image with the center point and size provided. Parameters ---------- @@ -225,10 +250,10 @@ def add_rectangle(self, center, size, annotation=False, rotation=0, name=""): The center position. size : {1} The size. The two values will be interpreted as the width and height, respectively. - annotation : {2} - Whether this region should be an annotation. Defaults to ``False``. - rotation : {3} + rotation : {2} The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. name : {4} The name. Defaults to the empty string. @@ -242,9 +267,36 @@ def add_rectangle(self, center, size, annotation=False, rotation=0, name=""): region_type = RegionType.ANNRECTANGLE if annotation else RegionType.RECTANGLE return self.add_region(region_type, [center, size], rotation, name) - @validate(Point.CoordinatePoint(), Point.SizePoint(), Boolean(), Number(), String()) - def add_ellipse(self, center, semi_axes, annotation=False, rotation=0, name=""): - """Add a new elliptical region or elliptical annotation to this image. + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), Number(), Boolean(), String()) + def add_rectangle_from_corners(self, bottom_left, top_right, rotation=0, annotation=False, name=""): + """Add a new rectangular region or rectangular annotation to this image with the bottom-left and top-right corners provided. + + Parameters + ---------- + bottom_left : {0} + The bottom-left corner position. + top_right : {1} + The top-right corner position. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` object + A new region object. + """ + [bottom_left] = self._from_world_coordinates([bottom_left]) + [top_right] = self._from_world_coordinates([top_right]) + center, size = self._center_size_from_corners(bottom_left, top_right) + return self.add_rectangle(center, size, rotation, annotation, name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_ellipse(self, center, semi_axes, rotation=0, annotation=False, name=""): + """Add a new elliptical region or elliptical annotation to this image with the center point and semi-axes provided. Parameters ---------- @@ -252,10 +304,10 @@ def add_ellipse(self, center, semi_axes, annotation=False, rotation=0, name=""): The center position. semi_axes : {1} The semi-axes. The two values will be interpreted as the north-south and east-west axes, respectively. - annotation : {2} - Whether this region should be an annotation. Defaults to ``False``. - rotation : {3} + rotation : {2} The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. name : {4} The name. Defaults to the empty string. @@ -270,6 +322,62 @@ def add_ellipse(self, center, semi_axes, annotation=False, rotation=0, name=""): region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE return self.add_region(region_type, [center, semi_axes], rotation, name) + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_ellipse_from_size(self, center, size, rotation=0, annotation=False, name=""): + """Add a new elliptical region or elliptical annotation to this image with the center point and size provided. + + The width and height will be used to calculate the semi-axes: the north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Parameters + ---------- + center : {0} + The center position. + size : {1} + The size. The two values will be interpreted as the width and height, respectively. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` object + A new region object. + """ + [size] = self._from_angular_sizes([size]) + width, height = size + semi_axes = height / 2, width / 2 + return self.add_ellipse(center, semi_axes, rotation, annotation, name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_ellipse_from_corners(self, bottom_left, top_right, rotation=0, annotation=False, name=""): + """Add a new elliptical region or elliptical annotation to this image with the bottom-left and top-right corners provided. + + Parameters + ---------- + bottom_left : {0} + The bottom-left corner position. + top_right : {1} + The top-right corner position. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` object + A new region object. + """ + [bottom_left] = self._from_world_coordinates([bottom_left]) + [top_right] = self._from_world_coordinates([top_right]) + center, size = self._center_size_from_corners(bottom_left, top_right) + return self.add_ellipse_from_size(center, size, rotation, annotation, name) + @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) def add_polygon(self, points, annotation=False, name=""): """Add a new polygonal region or polygonal annotation to this image. diff --git a/tests/test_region.py b/tests/test_region.py index a68dbe0..3c5b5af 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -161,21 +161,45 @@ def test_regionset_add_region(mocker, image): ("add_point", [(10, 10)], {"annotation": True}, [RT.ANNPOINT, [(10, 10)]], {"name": ""}), ("add_point", [(10, 10)], {"name": "my region"}, [RT.POINT, [(10, 10)]], {"name": "my region"}), - ("add_rectangle", [(10, 10), (20, 20)], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_rectangle", [("10", "10"), ("20", "20")], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_rectangle", [("10", "10"), (20, 20)], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_rectangle", [(10, 10), ("20", "20")], {}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_rectangle", [(10, 10), (20, 20)], {"annotation": True}, [RT.ANNRECTANGLE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_rectangle", [(10, 10), (20, 20)], {"name": "my region"}, [RT.RECTANGLE, [(10, 10), (20, 20)], 0, "my region"], {}), - ("add_rectangle", [(10, 10), (20, 20)], {"rotation": 45}, [RT.RECTANGLE, [(10, 10), (20, 20)], 45, ""], {}), - - ("add_ellipse", [(10, 10), (20, 20)], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_ellipse", [("10", "10"), ("20", "20")], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_ellipse", [("10", "10"), (20, 20)], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_ellipse", [(10, 10), ("20", "20")], {}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_ellipse", [(10, 10), (20, 20)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (20, 20)], 0, ""], {}), - ("add_ellipse", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (20, 20)], 0, "my region"], {}), - ("add_ellipse", [(10, 10), (20, 20)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (20, 20)], 45, ""], {}), + ("add_rectangle", [(10, 10), (20, 30)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [("10", "10"), ("20", "30")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [("10", "10"), (20, 30)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [(10, 10), ("20", "30")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [(10, 10), (20, 30)], {"annotation": True}, [RT.ANNRECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [(10, 10), (20, 30)], {"name": "my region"}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, "my region"], {}), + ("add_rectangle", [(10, 10), (20, 30)], {"rotation": 45}, [RT.RECTANGLE, [(10, 10), (20, 30)], 45, ""], {}), + + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [("0", "-5"), ("20", "25")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [("0", "-5"), (20, 25)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [(0, -5), ("20", "25")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {"annotation": True}, [RT.ANNRECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {"name": "my region"}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, "my region"], {}), + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {"rotation": 45}, [RT.RECTANGLE, [(10, 10), (20, 30)], 45, ""], {}), + + ("add_ellipse", [(10, 10), (10, 15)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [("10", "10"), ("10", "15")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [("10", "10"), (10, 15)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [(10, 10), ("10", "15")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [(10, 10), (10, 15)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [(10, 10), (10, 15)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, "my region"], {}), + ("add_ellipse", [(10, 10), (10, 15)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (10, 15)], 45, ""], {}), + + ("add_ellipse_from_size", [(10, 10), (30, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [("10", "10"), ("30", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [("10", "10"), (30, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [(10, 10), ("30", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [(10, 10), (30, 20)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [(10, 10), (30, 20)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, "my region"], {}), + ("add_ellipse_from_size", [(10, 10), (30, 20)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (10, 15)], 45, ""], {}), + + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [("-5", "0"), ("25", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [("-5", "0"), (25, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [(-5, 0), ("25", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, "my region"], {}), + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (10, 15)], 45, ""], {}), ("add_polygon", [[(10, 10), (20, 20), (30, 30)]], {}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), ("add_polygon", [[("10", "10"), ("20", "20"), ("30", "30")]], {}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), From 833b6b65d3c8063353dc5e2f978e0ae224e501c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 10 Nov 2025 17:08:49 +0200 Subject: [PATCH 41/50] Use magnitudes of sizes only in set_size; implement set_size and set_center for polygons and polylines --- carta/region.py | 56 ++++++++++++++++++++++++++++++++++++++++---- tests/test_region.py | 50 +++++++++++++++++++++++++++++++++------ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/carta/region.py b/carta/region.py index b0889b0..3c5ea37 100644 --- a/carta/region.py +++ b/carta/region.py @@ -834,13 +834,15 @@ def set_size(self, size): Both pixel and angular sizes are accepted, but both values must match. + Signs will be ignored; only the magnitudes of the values will be used. + Parameters ---------- size : {0} The new size. """ - [size] = self.region_set._from_angular_sizes([size]) - self.call_action("setSize", Pt(*size)) + [(w, h)] = self.region_set._from_angular_sizes([size]) + self.call_action("setSize", Pt(abs(w), abs(h))) @validate(Number(), Point.NumericPoint()) def set_control_point(self, index, point): @@ -1024,6 +1026,50 @@ def set_vertices(self, points): points = self.region_set._from_world_coordinates(points) self.set_control_points(points) + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + The region will be scaled to the size provided, with the center point location preserved. + + Both pixel and angular sizes are accepted, but both values must match. + + Signs will be ignored; only the magnitudes of the values will be used. + + This function will have no effect if any component of either the existing size or the provided size is zero. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [new] = self.region_set._from_angular_sizes([size]) + new = Pt(*new) + old = Pt(*self.size) + # No-op + if all((new.x, new.y, old.x, old.y)): + f = Pt(abs(new.x / old.x), abs(new.y / old.y)) + c = Pt(*self.center) + new_vertices = [((Pt(*v).x - c.x) * f.x + c.x, (Pt(*v).y - c.y) * f.y + c.y) for v in self.vertices] + self.set_vertices(new_vertices) + + @validate(Point.CoordinatePoint()) + def set_center(self, center): + """Set the center position. + + Both image and world coordinates are accepted, but both values must match. + + Parameters + ---------- + center : {0} + The new center position. + """ + [new] = self.region_set._from_world_coordinates([center]) + new = Pt(*new) + old = Pt(*self.center) + new_vertices = [(Pt(*v).x + new.x - old.x, Pt(*v).y + new.y - old.y) for v in self.vertices] + self.set_vertices(new_vertices) + class HasEndpointsMixin: """This is a mixin class for regions which are defined by two endpoints. It assumes that the region has two control points and both should be interpreted as coordinates.""" @@ -1115,7 +1161,7 @@ def set_length(self, length): rad = math.radians(self.rotation) - super().set_size((length * math.sin(rad), -1 * length * math.cos(rad))) + self.set_size((length * math.sin(rad), -1 * length * math.cos(rad))) class HasFontMixin: @@ -1611,8 +1657,8 @@ def set_size(self, size): size : {0} The new size. """ - [size] = self.region_set._from_angular_sizes([size]) - self.call_action("setLength", min(*size)) + [(w, h)] = self.region_set._from_angular_sizes([size]) + self.call_action("setLength", min(abs(w), abs(h))) @validate(*all_optional(String(), String())) def set_label(self, north_label=None, east_label=None): diff --git a/tests/test_region.py b/tests/test_region.py index 3c5b5af..e762f02 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -272,7 +272,7 @@ def test_region_type(image, get_value): assert region_type == RT.RECTANGLE -@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("region_type", {t for t in RT}) def test_center(region, get_value, region_type): reg = region(region_type) reg_get_value = get_value(reg, {"x": 20, "y": 30}) @@ -283,7 +283,7 @@ def test_center(region, get_value, region_type): assert center == (20, 30) -@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("region_type", {t for t in RT}) def test_wcs_center(region, property_, mock_to_world, region_type): reg = region(region_type) property_(reg)("center", (20, 30)) @@ -294,7 +294,7 @@ def test_wcs_center(region, property_, mock_to_world, region_type): assert wcs_center == ("20", "30") -@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("region_type", {t for t in RT}) def test_size(region, get_value, region_type): reg = region(region_type) @@ -314,7 +314,7 @@ def test_size(region, get_value, region_type): assert size == (20, 30) -@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("region_type", {t for t in RT}) def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): reg = region(region_type) @@ -361,7 +361,7 @@ def test_simple_properties(region, get_value, method_name, value_name): assert value == "dummy" -@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) @pytest.mark.parametrize("value,expected_value", [ ((20, 30), Pt(20, 30)), (("20", "30"), Pt(20, 30)), @@ -373,11 +373,28 @@ def test_set_center(region, mock_from_world, call_action, region_type, value, ex mock_call.assert_called_with("setCenter", expected_value) -@pytest.mark.parametrize("region_type", [t for t in RT]) +@pytest.mark.parametrize("region_type", {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("value,expected_value", [ + ((15, 25), [(5, 15), (15, 35), (25, 25)]), + (("15", "25"), [(5, 15), (15, 35), (25, 25)]), +]) +def test_set_center_poly(region, mock_from_world, method, property_, region_type, value, expected_value): + reg = region(region_type) + + property_(reg)("vertices", [(10, 10), (20, 30), (30, 20)]) + property_(reg)("center", (20, 20)) + mock_set_vertices = method(reg)("set_vertices", None) + reg.set_center(value) + + mock_set_vertices.assert_called_with(expected_value) + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) @pytest.mark.parametrize("value,expected_value", [ ((20, 30), Pt(20, 30)), - ((-20, -30), Pt(-20, -30)), + ((-20, -30), Pt(20, 30)), (("20", "30"), Pt(20, 30)), + (("-20", "-30"), Pt(20, 30)), ]) def test_set_size(region, mock_from_angular, call_action, region_type, value, expected_value): reg = region(region_type) @@ -392,6 +409,25 @@ def test_set_size(region, mock_from_angular, call_action, region_type, value, ex mock_call.assert_called_with("setSize", expected_value) +@pytest.mark.parametrize("region_type", {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), + ((-20, -30), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), + (("20", "30"), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), + (("-20", "-30"), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), +]) +def test_set_size_poly(region, mock_from_angular, method, property_, region_type, value, expected_value): + reg = region(region_type) + property_(reg)("vertices", [(10, 10), (20, 30), (30, 20)]) + property_(reg)("size", (20, 20)) + property_(reg)("center", (20, 20)) + mock_set_vertices = method(reg)("set_vertices", None) + + reg.set_size(value) + + mock_set_vertices.assert_called_with(expected_value) + + def test_set_control_point(region, call_action): reg = region() mock_call = call_action(reg) From fd1c6578daff427a1c4c2595382ac25f7d65ba06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 10 Nov 2025 17:22:36 +0200 Subject: [PATCH 42/50] added property to scale regions by a numeric factor --- carta/region.py | 14 ++++++++++++++ tests/test_region.py | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/carta/region.py b/carta/region.py index 3c5ea37..a4dda07 100644 --- a/carta/region.py +++ b/carta/region.py @@ -844,6 +844,20 @@ def set_size(self, size): [(w, h)] = self.region_set._from_angular_sizes([size]) self.call_action("setSize", Pt(abs(w), abs(h))) + @validate(Number()) + def scale(self, factor): + """Scale by the factor provided. + + The sign will be ignored; only the magnitude of the value will be used. + + Parameters + ---------- + factor : {0} + The scaling factor to apply to the region. + """ + w, h = self.size + self.set_size((factor * w, factor * h)) + @validate(Number(), Point.NumericPoint()) def set_control_point(self, index, point): """Update the value of a single control point. diff --git a/tests/test_region.py b/tests/test_region.py index e762f02..6e18f35 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -428,6 +428,16 @@ def test_set_size_poly(region, mock_from_angular, method, property_, region_type mock_set_vertices.assert_called_with(expected_value) +def test_scale(region, method, property_): + reg = region() + property_(reg)("size", (20, 30)) + mock_set_size = method(reg)("set_size", None) + + reg.scale(2) + + mock_set_size.assert_called_with((40, 60)) + + def test_set_control_point(region, call_action): reg = region() mock_call = call_action(reg) From 035954d0395b2194764d2ef028f1154f4389c038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 11 Nov 2025 14:10:01 +0200 Subject: [PATCH 43/50] split size properties out into mixin and update tests; disable annulus type entirely (iteraing over the types should only return supported types) --- carta/constants.py | 2 +- carta/region.py | 236 +++++++++++++++++++++++-------------------- tests/test_region.py | 66 ++++++------ 3 files changed, 165 insertions(+), 139 deletions(-) diff --git a/carta/constants.py b/carta/constants.py index 2b0229e..b5d0fbd 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -239,7 +239,7 @@ def __init__(self, value): POLYLINE = 2 RECTANGLE = 3 ELLIPSE = 4 - ANNULUS = 5 + # ANNULUS = 5 is not actually implemented POLYGON = 6 ANNPOINT = 7 ANNLINE = 8 diff --git a/carta/region.py b/carta/region.py index a4dda07..22fbf36 100644 --- a/carta/region.py +++ b/carta/region.py @@ -725,38 +725,6 @@ def wcs_center(self): [center] = self.region_set.image.to_world_coordinate_points([self.center]) return center - @property - def size(self): - """The size, in pixels. - - Returns - ------- - number - The width. - number - The height. - """ - size = self.get_value("size") - if not size: - return None - return Pt(**size).as_tuple() - - @property - def wcs_size(self): - """The size, in angular size units. - - Returns - ------- - string - The width. - string - The height. - """ - size = self.get_value("wcsSize") - if size['x'] is None or size['y'] is None: - return None - return (f"{size['x']}\"", f"{size['y']}\"") - @property def control_points(self): """The control points. @@ -790,28 +758,6 @@ def color(self): """ return self.get_value("color") - @property - def line_width(self): - """The line width. - - Returns - ------- - number - The line width, in pixels. - """ - return self.get_value("lineWidth") - - @property - def dash_length(self): - """The dash length. - - Returns - ------- - number - The dash length, in pixels. - """ - return self.get_value("dashLength") - # SET PROPERTIES @validate(Point.CoordinatePoint()) @@ -828,36 +774,6 @@ def set_center(self, center): [center] = self.region_set._from_world_coordinates([center]) self.call_action("setCenter", Pt(*center)) - @validate(Point.SizePoint()) - def set_size(self, size): - """Set the size. - - Both pixel and angular sizes are accepted, but both values must match. - - Signs will be ignored; only the magnitudes of the values will be used. - - Parameters - ---------- - size : {0} - The new size. - """ - [(w, h)] = self.region_set._from_angular_sizes([size]) - self.call_action("setSize", Pt(abs(w), abs(h))) - - @validate(Number()) - def scale(self, factor): - """Scale by the factor provided. - - The sign will be ignored; only the magnitude of the value will be used. - - Parameters - ---------- - factor : {0} - The scaling factor to apply to the region. - """ - w, h = self.size - self.set_size((factor * w, factor * h)) - @validate(Number(), Point.NumericPoint()) def set_control_point(self, index, point): """Update the value of a single control point. @@ -893,27 +809,16 @@ def set_name(self, name): """ self.call_action("setName", name) - @validate(*all_optional(Color(), Number(), Number())) - def set_line_style(self, color=None, line_width=None, dash_length=None): - """Set the line style. - - All parameters are optional. Omitted properties will be left unmodified. + @validate(Color()) + def set_color(self, color): + """Set the color. Parameters ---------- color : {0} The new color. - line_width : {1} - The new line width, in pixels. - dash_length : {2} - The new dash length, in pixels. """ - if color is not None: - self.call_action("setColor", color) - if line_width is not None: - self.call_action("setLineWidth", line_width) - if dash_length is not None: - self.call_action("setDashLength", dash_length) + self.call_action("setColor", color) def lock(self): """Lock this region.""" @@ -948,7 +853,118 @@ def delete(self): """Delete this region.""" self.region_set.call_action("deleteRegion", self._region) -# TODO also factor out size, and exclude it from the point region? +# TODO conversion methods: as_polygon(num_points), as_polyline(num_points) -- only for: rectangle, ellipse, line (vector or not? check if polyline has end styles) + + +class HasSizeMixin: + """This is a mixin class for regions which have a size (all of them except for point regions and annotations). These regions also have a line style. + + Subclasses which have size functions partially implemented natively should use this mixin and override unimplemented functions as appropriate. + """ + + # GET PROPERTIES + + @property + def size(self): + """The size, in pixels. + + Returns + ------- + number + The width. + number + The height. + """ + size = self.get_value("size") + return Pt(**size).as_tuple() + + @property + def wcs_size(self): + """The size, in angular size units. + + Returns + ------- + string + The width. + string + The height. + """ + size = self.get_value("wcsSize") + return (f"{size['x']}\"", f"{size['y']}\"") + + @property + def line_width(self): + """The line width. + + Returns + ------- + number + The line width, in pixels. + """ + return self.get_value("lineWidth") + + @property + def dash_length(self): + """The dash length. + + Returns + ------- + number + The dash length, in pixels. + """ + return self.get_value("dashLength") + + # SET PROPERTIES + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. + + Signs will be ignored; only the magnitudes of the values will be used. + + Parameters + ---------- + size : {0} + The new size. + """ + [(w, h)] = self.region_set._from_angular_sizes([size]) + self.call_action("setSize", Pt(abs(w), abs(h))) + + @validate(Number()) + def scale(self, factor): + """Scale by the factor provided. + + The sign will be ignored; only the magnitude of the value will be used. + + Parameters + ---------- + factor : {0} + The scaling factor to apply to the region. + """ + w, h = self.size + self.set_size((factor * w, factor * h)) + + @validate(*all_optional(Number(), Number())) + def set_line_style(self, line_width=None, dash_length=None): + """Set the line style. + + All parameters are optional. Omitted properties will be left unmodified. + + To set the line color, see :obj:`carta.region.Region.set_color`. + + Parameters + ---------- + line_width : {0} + The new line width, in pixels. + dash_length : {1} + The new dash length, in pixels. + """ + if line_width is not None: + self.call_action("setLineWidth", line_width) + if dash_length is not None: + self.call_action("setDashLength", dash_length) class HasRotationMixin: @@ -1290,25 +1306,25 @@ def set_pointer_style(self, pointer_width=None, pointer_length=None): self.call_action("setPointerLength", pointer_length) -class LineRegion(HasEndpointsMixin, HasRotationMixin, Region): +class LineRegion(HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): """A line region or annotation.""" REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) """The region types corresponding to this class.""" -class PolylineRegion(HasVerticesMixin, Region): +class PolylineRegion(HasVerticesMixin, HasSizeMixin, Region): """A polyline region or annotation.""" REGION_TYPES = (RegionType.POLYLINE, RegionType.ANNPOLYLINE) """The region types corresponding to this class.""" -class PolygonRegion(HasVerticesMixin, Region): +class PolygonRegion(HasVerticesMixin, HasSizeMixin, Region): """A polygonal region or annotation.""" REGION_TYPES = (RegionType.POLYGON, RegionType.ANNPOLYGON) """The region types corresponding to this class.""" -class RectangularRegion(HasRotationMixin, Region): +class RectangularRegion(HasRotationMixin, HasSizeMixin, Region): """A rectangular region or annotation.""" REGION_TYPES = (RegionType.RECTANGLE, RegionType.ANNRECTANGLE) """The region types corresponding to this class.""" @@ -1384,7 +1400,7 @@ def set_corners(self, bottom_left=None, top_right=None): self.set_control_points([center, size.as_tuple()]) -class EllipticalRegion(HasRotationMixin, Region): +class EllipticalRegion(HasRotationMixin, HasSizeMixin, Region): """An elliptical region or annotation.""" REGION_TYPES = (RegionType.ELLIPSE, RegionType.ANNELLIPSE) """The region types corresponding to this class.""" @@ -1537,7 +1553,7 @@ def set_point_style(self, point_shape=None, point_width=None): self.call_action("setPointWidth", point_width) -class TextAnnotation(HasFontMixin, HasRotationMixin, Region): +class TextAnnotation(HasFontMixin, HasRotationMixin, HasSizeMixin, Region): """A text annotation.""" REGION_TYPES = (RegionType.ANNTEXT,) """The region types corresponding to this class.""" @@ -1591,13 +1607,15 @@ def set_text_position(self, text_position): self.call_action("setPosition", text_position) -class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, Region): +class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): """A vector annotation.""" REGION_TYPES = (RegionType.ANNVECTOR,) """The region types corresponding to this class.""" +# TODO TODO TODO should we give this a length and scale only?? Does this have a line style? + -class CompassAnnotation(HasFontMixin, HasPointerMixin, Region): +class CompassAnnotation(HasFontMixin, HasPointerMixin, HasSizeMixin, Region): """A compass annotation.""" REGION_TYPES = (RegionType.ANNCOMPASS,) """The region types corresponding to this class.""" @@ -1757,7 +1775,7 @@ def set_arrowhead_visible(self, north=None, east=None): self.call_action("setEastArrowhead", east) -class RulerAnnotation(HasFontMixin, HasEndpointsMixin, HasRotationMixin, Region): +class RulerAnnotation(HasFontMixin, HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): """A ruler annotation.""" REGION_TYPES = (RegionType.ANNRULER,) """The region types corresponding to this class.""" diff --git a/tests/test_region.py b/tests/test_region.py index 6e18f35..73e7648 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -1,7 +1,7 @@ import pytest import math -from carta.region import Region +from carta.region import Region, HasSizeMixin from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS, TextPosition as TP, SpatialAxis as SA from carta.util import Point as Pt, Macro @@ -294,36 +294,27 @@ def test_wcs_center(region, property_, mock_to_world, region_type): assert wcs_center == ("20", "30") -@pytest.mark.parametrize("region_type", {t for t in RT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) def test_size(region, get_value, region_type): reg = region(region_type) - - if region_type in {RT.POINT, RT.ANNPOINT}: - reg_get_value = get_value(reg, None) - else: - reg_get_value = get_value(reg, {"x": 20, "y": 30}) + reg_get_value = get_value(reg, {"x": 20, "y": 30}) size = reg.size reg_get_value.assert_called_with("size") if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: assert size == (60, 40) # The frontend size returned for an ellipse is the semi-axes, which we double and swap - elif region_type in {RT.POINT, RT.ANNPOINT}: - assert size is None # Test that returned null/undefined size for a point is converted to None as expected else: assert size == (20, 30) -@pytest.mark.parametrize("region_type", {t for t in RT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): reg = region(region_type) if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: # Bypasses wcsSize to call own (overridden) size and converts to angular units property_(reg)("size", (20, 30)) - elif region_type in {RT.POINT, RT.ANNPOINT}: - # Simulate undefined size - reg_get_value = get_value(reg, {"x": None, "y": None}) else: reg_get_value = get_value(reg, {"x": "20", "y": "30"}) @@ -332,9 +323,6 @@ def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: mock_to_angular.assert_called_with([(20, 30)]) assert size == ("20", "30") - elif region_type in {RT.POINT, RT.ANNPOINT}: - reg_get_value.assert_called_with("wcsSize") - assert size is None else: reg_get_value.assert_called_with("wcsSize") assert size == ("20\"", "30\"") @@ -350,11 +338,22 @@ def test_control_points(region, get_value): @pytest.mark.parametrize("method_name,value_name", [ ("name", "name"), ("color", "color"), +]) +def test_common_properties(region, get_value, method_name, value_name): + reg = region() + mock_value_getter = get_value(reg, "dummy") + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == "dummy" + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("method_name,value_name", [ ("line_width", "lineWidth"), ("dash_length", "dashLength"), ]) -def test_simple_properties(region, get_value, method_name, value_name): - reg = region() +def test_line_style_properties(region, get_value, region_type, method_name, value_name): + reg = region(region_type) mock_value_getter = get_value(reg, "dummy") value = getattr(reg, method_name) mock_value_getter.assert_called_with(value_name) @@ -389,7 +388,7 @@ def test_set_center_poly(region, mock_from_world, method, property_, region_type mock_set_vertices.assert_called_with(expected_value) -@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) @pytest.mark.parametrize("value,expected_value", [ ((20, 30), Pt(20, 30)), ((-20, -30), Pt(20, 30)), @@ -428,8 +427,9 @@ def test_set_size_poly(region, mock_from_angular, method, property_, region_type mock_set_vertices.assert_called_with(expected_value) -def test_scale(region, method, property_): - reg = region() +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +def test_scale(region, method, property_, region_type): + reg = region(region_type) property_(reg)("size", (20, 30)) mock_set_size = method(reg)("set_size", None) @@ -459,14 +459,22 @@ def test_set_name(region, call_action): mock_call.assert_called_with("setName", "My region name") +def test_set_color(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.set_color("blue") + mock_call.assert_called_with("setColor", "blue") + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) @pytest.mark.parametrize("args,kwargs,expected_calls", [ ([], {}, []), - (["blue", 2, 3], {}, [("setColor", "blue"), ("setLineWidth", 2), ("setDashLength", 3)]), - (["blue"], {"dash_length": 3}, [("setColor", "blue"), ("setDashLength", 3)]), + ([2, 3], {}, [("setLineWidth", 2), ("setDashLength", 3)]), + ([2], {"dash_length": 3}, [("setLineWidth", 2), ("setDashLength", 3)]), ([], {"line_width": 2}, [("setLineWidth", 2)]), ]) -def test_set_line_style(mocker, region, call_action, args, kwargs, expected_calls): - reg = region() +def test_set_line_style(mocker, region, call_action, region_type, args, kwargs, expected_calls): + reg = region(region_type) mock_call = call_action(reg) reg.set_line_style(*args, **kwargs) mock_call.assert_has_calls([mocker.call(*c) for c in expected_calls]) @@ -624,7 +632,7 @@ def test_set_length(mocker, region, property_, region_type, length): property_(reg)("length", 100) property_(reg)("wcs_length", "100") property_(reg)("rotation", 45) - mock_region_set_size = mocker.patch.object(Region, "set_size") + mock_region_set_size = mocker.patch.object(HasSizeMixin, "set_size") reg.set_length(length) @@ -735,7 +743,7 @@ def test_set_corners(region, method, property_, mock_from_world, region_type, ar @pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) def test_semi_axes(mocker, region, region_type): reg = region(region_type) - mocker.patch("carta.region.Region.size", new_callable=mocker.PropertyMock, return_value=(20, 30)) + mocker.patch("carta.region.HasSizeMixin.size", new_callable=mocker.PropertyMock, return_value=(20, 30)) semi_axes = reg.semi_axes @@ -745,7 +753,7 @@ def test_semi_axes(mocker, region, region_type): @pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) def test_wcs_semi_axes(mocker, region, region_type): reg = region(region_type) - mocker.patch("carta.region.Region.wcs_size", new_callable=mocker.PropertyMock, return_value=("20", "30")) + mocker.patch("carta.region.HasSizeMixin.wcs_size", new_callable=mocker.PropertyMock, return_value=("20", "30")) semi_axes = reg.wcs_semi_axes @@ -756,7 +764,7 @@ def test_wcs_semi_axes(mocker, region, region_type): @pytest.mark.parametrize("semi_axes", [(20, 30), ("20", "30")]) def test_set_semi_axes(mocker, region, mock_from_angular, region_type, semi_axes): reg = region(region_type) - mock_region_set_size = mocker.patch.object(Region, "set_size") + mock_region_set_size = mocker.patch.object(HasSizeMixin, "set_size") reg.set_semi_axes(semi_axes) From 57a4e24ec5d767423884883f286fe5cafc21fb46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Wed, 12 Nov 2025 17:50:50 +0200 Subject: [PATCH 44/50] Added polygon approximation for ellipses; tests TBD --- carta/region.py | 87 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/carta/region.py b/carta/region.py index 22fbf36..539e3af 100644 --- a/carta/region.py +++ b/carta/region.py @@ -259,7 +259,7 @@ def add_rectangle(self, center, size, rotation=0, annotation=False, name=""): Returns ------- - :obj:`carta.region.Region` object + :obj:`carta.region.RectangularRegion` object A new region object. """ [center] = self._from_world_coordinates([center]) @@ -286,7 +286,7 @@ def add_rectangle_from_corners(self, bottom_left, top_right, rotation=0, annotat Returns ------- - :obj:`carta.region.Region` object + :obj:`carta.region.RectangularRegion` object A new region object. """ [bottom_left] = self._from_world_coordinates([bottom_left]) @@ -313,7 +313,7 @@ def add_ellipse(self, center, semi_axes, rotation=0, annotation=False, name=""): Returns ------- - :obj:`carta.region.Region` object + :obj:`carta.region.EllipticalRegion` object A new region object. """ [center] = self._from_world_coordinates([center]) @@ -343,7 +343,7 @@ def add_ellipse_from_size(self, center, size, rotation=0, annotation=False, name Returns ------- - :obj:`carta.region.Region` object + :obj:`carta.region.EllipticalRegion` object A new region object. """ [size] = self._from_angular_sizes([size]) @@ -370,7 +370,7 @@ def add_ellipse_from_corners(self, bottom_left, top_right, rotation=0, annotatio Returns ------- - :obj:`carta.region.Region` object + :obj:`carta.region.EllipticalRegion` object A new region object. """ [bottom_left] = self._from_world_coordinates([bottom_left]) @@ -393,7 +393,7 @@ def add_polygon(self, points, annotation=False, name=""): Returns ------- - :obj:`carta.region.PolygonRegion` or :obj:`carta.region.PolygonAnnotation` object + :obj:`carta.region.PolygonRegion` object A new region object. """ points = self._from_world_coordinates(points) @@ -417,7 +417,7 @@ def add_line(self, start, end, annotation=False, name=""): Returns ------- - :obj:`carta.region.LineRegion` or :obj:`carta.region.LineAnnotation` object + :obj:`carta.region.LineRegion` object A new region object. """ [start, end] = self._from_world_coordinates([start, end]) @@ -439,7 +439,7 @@ def add_polyline(self, points, annotation=False, name=""): Returns ------- - :obj:`carta.region.PolylineRegion` or :obj:`carta.region.PolylineAnnotation` object + :obj:`carta.region.PolylineRegion` object A new region object. """ points = self._from_world_coordinates(points) @@ -853,8 +853,6 @@ def delete(self): """Delete this region.""" self.region_set.call_action("deleteRegion", self._region) -# TODO conversion methods: as_polygon(num_points), as_polyline(num_points) -- only for: rectangle, ellipse, line (vector or not? check if polyline has end styles) - class HasSizeMixin: """This is a mixin class for regions which have a size (all of them except for point regions and annotations). These regions also have a line style. @@ -983,6 +981,17 @@ def rotation(self): """ return self.get_value("rotation") + @property + def rad_rotation(self): + """The rotation, in radians. + + Returns + ------- + number + The rotation. + """ + return math.radians(self.rotation) + # SET PROPERTIES @validate(Number()) @@ -996,6 +1005,17 @@ def set_rotation(self, angle): """ self.call_action("setRotation", angle) + @validate(Number()) + def set_rad_rotation(self, angle): + """Set the rotation. + + Parameters + ---------- + angle : {0} + The new rotation, in radians. + """ + self.set_rotation(math.degrees(angle)) + class HasVerticesMixin: """This is a mixin class for regions which are defined by an arbitrary number of vertices. It assumes that all control points of the region should be interpreted as coordinates.""" @@ -1189,7 +1209,7 @@ def set_length(self, length): if isinstance(length, str): length = self.length * AngularSize.from_string(length).arcsec / AngularSize.from_string(self.wcs_length).arcsec - rad = math.radians(self.rotation) + rad = self.rad_rotation self.set_size((length * math.sin(rad), -1 * length * math.cos(rad))) @@ -1502,6 +1522,49 @@ def set_size(self, size): width, height = size super().set_size([height / 2, width / 2]) + # CONVERSION + + @validate(*all_optional(Number(min=12, step=4), Boolean())) + def as_polygon(self, num_points=12, delete=False): + """Return a polygon approximation of this region or annotation. + + Parameters + ---------- + num_points : {0} + The number of vertices to use for the approximation. + delete : {1} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + center = Pt(*self.center) + b, a = self.semi_axes + rot = self.rad_rotation + + angles = [i * 2 * math.pi / num_points for i in range(num_points)] + # TODO biased vertex distributions based on eccentricity: quadrant functions + + points = [] + sin_rot, cos_rot = math.sin(rot), math.cos(rot) + + for theta in angles: + rot_a = a * math.cos(theta) + rot_b = b * math.sin(theta) + x = center.x + cos_rot * rot_a - sin_rot * rot_b + y = center.y + sin_rot * rot_a + cos_rot * rot_b + points.append((x, y)) + + polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNELLIPSE), name=self.name) + polygon.set_color(self.color) + + if delete: + self.delete() + + return polygon + class PointAnnotation(Region): """A point annotation.""" @@ -1612,8 +1675,6 @@ class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, Has REGION_TYPES = (RegionType.ANNVECTOR,) """The region types corresponding to this class.""" -# TODO TODO TODO should we give this a length and scale only?? Does this have a line style? - class CompassAnnotation(HasFontMixin, HasPointerMixin, HasSizeMixin, Region): """A compass annotation.""" From cb8b011f3438b65abdd434d0664a1c2a141e65c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Fri, 14 Nov 2025 14:21:13 +0200 Subject: [PATCH 45/50] Fixed ellipse polygon approximation, added approximations (no oversampling) for rectangle and line. TBD: functions for creating polygons like this from the start; unit tests --- carta/region.py | 99 +++++++++++++++++++++++++++++++++++++++++++++---- carta/units.py | 2 - 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/carta/region.py b/carta/region.py index 539e3af..ebae57e 100644 --- a/carta/region.py +++ b/carta/region.py @@ -1331,6 +1331,36 @@ class LineRegion(HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) """The region types corresponding to this class.""" + # CONVERSION + + @validate(NoneOr(Boolean())) + def as_polyline(self, delete=False): + """Return this region or annotation as a polyline. + + Parameters + ---------- + delete : {0} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolylineRegion` object + A new region object. + """ + + # The endpoints of a line have the rotation already applied, so we can use them as-is + start, end = self.endpoints + # We MUST include a third point (for now) because the frontend requires polylines to have at least 3 points + points = [start, self.center, end] + + polygon = self.region_set.add_polyline(points, annotation=(self.region_type == RegionType.ANNLINE), name=self.name) + polygon.set_color(self.color) + + if delete: + self.delete() + + return polygon + class PolylineRegion(HasVerticesMixin, HasSizeMixin, Region): """A polyline region or annotation.""" @@ -1419,6 +1449,51 @@ def set_corners(self, bottom_left=None, top_right=None): self.set_control_points([center, size.as_tuple()]) + # CONVERSION + + @validate(NoneOr(Boolean())) + def as_polygon(self, delete=False): + """Return this region or annotation as a polygon. + + Parameters + ---------- + delete : {0} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + + center = Pt(*self.center) + w, h = self.size + rot = self.rad_rotation + + deltas = ( + (-w / 2, -h / 2), + (-w / 2, h / 2), + (w / 2, h / 2), + (w / 2, -h / 2), + ) + + points = [] + + sin_rot, cos_rot = math.sin(rot), math.cos(rot) + + for (dx, dy) in deltas: + x = center.x + dx * cos_rot - dy * sin_rot + y = center.y + dy * cos_rot + dx * sin_rot + points.append((x, y)) + + polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNRECTANGLE), name=self.name) + polygon.set_color(self.color) + + if delete: + self.delete() + + return polygon + class EllipticalRegion(HasRotationMixin, HasSizeMixin, Region): """An elliptical region or annotation.""" @@ -1524,15 +1599,19 @@ def set_size(self, size): # CONVERSION - @validate(*all_optional(Number(min=12, step=4), Boolean())) - def as_polygon(self, num_points=12, delete=False): + @validate(*all_optional(Number(min=4), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def as_polygon(self, num_vertices=None, vertices_per_degree=10, delete=False): """Return a polygon approximation of this region or annotation. + By default, the number of vertices to use for the approximation is derived from the angular size of the ellipse circumference and the configured number of vertices per degree, with a minimum of 12. Vertices will be distributed more densely near the major axis and more sparsely near the minor axis. + Parameters ---------- - num_points : {0} - The number of vertices to use for the approximation. - delete : {1} + num_vertices : {0} + The number of vertices to use. If this parameter is not provided, the number is generated dynamically. + vertices_per_degree : {1} + The approximate number of vertices to add per degree of the ellipse circumference (the default is 10). This parameter is ignored if an exact number of vertices is provided. + delete : {2} Whether to delete the original region. Returns @@ -1544,8 +1623,14 @@ def as_polygon(self, num_points=12, delete=False): b, a = self.semi_axes rot = self.rad_rotation - angles = [i * 2 * math.pi / num_points for i in range(num_points)] - # TODO biased vertex distributions based on eccentricity: quadrant functions + if num_vertices is None: + # Semi-axes in arcseconds + wcs_b, wcs_a = (AngularSize.from_string(ax).arcsec for ax in self.wcs_semi_axes) + perimeter = math.pi * (3 * (wcs_a + wcs_b) - math.sqrt((3 * wcs_a + wcs_b) * (3 * wcs_b + wcs_a))) + # Approximately 10 vertices per degree (minimum: 12) + num_vertices = max(round(vertices_per_degree * perimeter / 3600), 12) + + angles = [i * 2 * math.pi / num_vertices for i in range(num_vertices)] points = [] sin_rot, cos_rot = math.sin(rot), math.cos(rot) diff --git a/carta/units.py b/carta/units.py index c3965f1..05ed965 100644 --- a/carta/units.py +++ b/carta/units.py @@ -103,8 +103,6 @@ def from_arcsec(cls, arcsec): If this method is called on the parent :obj:`carta.units.AngularSize` class, it will automatically guess the most appropriate unit subclass. If it is called on a unit subclass, it will return an instance of that subclass. - If this method is called on the This method automatically guesses the most appropriate unit. - Parameters ---------- arcsec : float From 581d2592a53ecbde6b48a06a99507ca863fe58e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 17 Nov 2025 16:02:14 +0200 Subject: [PATCH 46/50] Added oversampling for rectangle and line polygons / polylines. Overrode center for polylines and polygons. Implemented rotation and translation methods for consistency. Cleaned up unhelpful use of point objects. --- carta/region.py | 286 ++++++++++++++++++++++++++++--------------- tests/test_region.py | 12 +- 2 files changed, 200 insertions(+), 98 deletions(-) diff --git a/carta/region.py b/carta/region.py index ebae57e..afe9f3e 100644 --- a/carta/region.py +++ b/carta/region.py @@ -193,31 +193,6 @@ def _from_angular_sizes(self, points): pass return points - def _center_size_from_corners(self, bottom_left, top_right): - """Internal utility function for calculating a center point and a size from a bottom-left and top-right corner. - - The corner points provided must be in image coordinates, and the returned center and size values are in image coordinates and pixel sizes, respectively. - - Parameters - ---------- - bottom_left : point in image coordinates - The bottom-left corner. - top_right : point in image coordinates - The top-right corner. - - Returns - ------- - point in image coordinates - The center point. - pair of pixel sizes - The size. - """ - bottom_left = Pt(*bottom_left) - top_right = Pt(*top_right) - size = Pt(top_right.x - bottom_left.x, top_right.y - bottom_left.y) - center = size.x / 2 + bottom_left.x, size.y / 2 + bottom_left.y - return center, size.as_tuple() - @validate(Point.CoordinatePoint(), Boolean(), String()) def add_point(self, center, annotation=False, name=""): """Add a new point region or point annotation to this image. @@ -291,7 +266,7 @@ def add_rectangle_from_corners(self, bottom_left, top_right, rotation=0, annotat """ [bottom_left] = self._from_world_coordinates([bottom_left]) [top_right] = self._from_world_coordinates([top_right]) - center, size = self._center_size_from_corners(bottom_left, top_right) + center, size = RectangularRegion._center_size_from_corners(bottom_left, top_right) return self.add_rectangle(center, size, rotation, annotation, name) @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) @@ -375,10 +350,10 @@ def add_ellipse_from_corners(self, bottom_left, top_right, rotation=0, annotatio """ [bottom_left] = self._from_world_coordinates([bottom_left]) [top_right] = self._from_world_coordinates([top_right]) - center, size = self._center_size_from_corners(bottom_left, top_right) + center, size = RectangularRegion._center_size_from_corners(bottom_left, top_right) return self.add_ellipse_from_size(center, size, rotation, annotation, name) - @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) + @validate(Union(IterableOf(Point.NumericPoint(), 3), IterableOf(Point.WorldCoordinatePoint(), 3)), Boolean(), String()) def add_polygon(self, points, annotation=False, name=""): """Add a new polygonal region or polygonal annotation to this image. @@ -424,7 +399,7 @@ def add_line(self, start, end, annotation=False, name=""): region_type = RegionType.ANNLINE if annotation else RegionType.LINE return self.add_region(region_type, [start, end], name=name) - @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint())), Boolean(), String()) + @validate(Union(IterableOf(Point.NumericPoint(), 3), IterableOf(Point.WorldCoordinatePoint(), 3)), Boolean(), String()) def add_polyline(self, points, annotation=False, name=""): """Add a new polyline region or polyline annotation to this image. @@ -774,6 +749,21 @@ def set_center(self, center): [center] = self.region_set._from_world_coordinates([center]) self.call_action("setCenter", Pt(*center)) + @validate(Point.SizePoint()) + def translate(self, delta): + """Translate the region by the measurement provided. + + Both pixel and angular measurements are accepted, but both values must match. + + Parameters + ---------- + delta : {0} + The translation distance. + """ + [(cx, cy)] = self.region_set._from_world_coordinates([self.center]) + [(dx, dy)] = self.region_set._from_angular_sizes([delta]) + self.set_center((cx + dx, cy + dy)) + @validate(Number(), Point.NumericPoint()) def set_control_point(self, index, point): """Update the value of a single control point. @@ -981,17 +971,6 @@ def rotation(self): """ return self.get_value("rotation") - @property - def rad_rotation(self): - """The rotation, in radians. - - Returns - ------- - number - The rotation. - """ - return math.radians(self.rotation) - # SET PROPERTIES @validate(Number()) @@ -1006,22 +985,40 @@ def set_rotation(self, angle): self.call_action("setRotation", angle) @validate(Number()) - def set_rad_rotation(self, angle): - """Set the rotation. + def rotate(self, rotation): + """Rotate this region. + + The rotation provided will be added to the current rotation of the region. Parameters ---------- - angle : {0} - The new rotation, in radians. + rotation : {0} + The rotation to apply, in degrees. """ - self.set_rotation(math.degrees(angle)) + self.set_rotation(self.rotation + rotation) class HasVerticesMixin: - """This is a mixin class for regions which are defined by an arbitrary number of vertices. It assumes that all control points of the region should be interpreted as coordinates.""" + """This is a mixin class for regions which are defined by an arbitrary number of vertices (polygons and polylines). It assumes that all control points of the region should be interpreted as coordinates.""" # GET PROPERTIES + @property + def center(self): + """The geometric center, in image coordinates. + + This overrides the native behaviour of the frontend (which returns the center point of the region bounding box). + + Returns + ------- + number + The X coordinate of the geometric center. + number + The Y coordinate of the geometric center. + """ + all_vx, all_vy = zip(*self.vertices) + return (sum(all_vx) / len(all_vx), sum(all_vy) / len(all_vy)) + @property def vertices(self): """The vertices, in image coordinates. @@ -1062,7 +1059,7 @@ def set_vertex(self, index, point): [point] = self.region_set._from_world_coordinates([point]) self.set_control_point(index, point) - @validate(Union(IterableOf(Point.NumericPoint()), IterableOf(Point.WorldCoordinatePoint()))) + @validate(Union(IterableOf(Point.NumericPoint(), 3), IterableOf(Point.WorldCoordinatePoint(), 3))) def set_vertices(self, points): """Update all the vertices. @@ -1080,7 +1077,9 @@ def set_vertices(self, points): def set_size(self, size): """Set the size. - The region will be scaled to the size provided, with the center point location preserved. + The size of a polygon or polyline region is computed as the size of its bounding box. + + The region will be scaled to the size provided, with the geometric center preserved. Both pixel and angular sizes are accepted, but both values must match. @@ -1093,19 +1092,20 @@ def set_size(self, size): size : {0} The new width and height, in that order. """ - [new] = self.region_set._from_angular_sizes([size]) - new = Pt(*new) - old = Pt(*self.size) + [(nw, nh)] = self.region_set._from_angular_sizes([size]) + w, h = self.size # No-op - if all((new.x, new.y, old.x, old.y)): - f = Pt(abs(new.x / old.x), abs(new.y / old.y)) - c = Pt(*self.center) - new_vertices = [((Pt(*v).x - c.x) * f.x + c.x, (Pt(*v).y - c.y) * f.y + c.y) for v in self.vertices] + if all((nw, nh, w, h)): + fx, fy = abs(nw / w), abs(nh / h) + cx, cy = self.center + new_vertices = [((vx - cx) * fx + cx, (vy - cy) * fy + cy) for (vx, vy) in self.vertices] self.set_vertices(new_vertices) @validate(Point.CoordinatePoint()) def set_center(self, center): - """Set the center position. + """Set the geometric center. + + Each vertex in the region will be translated by the difference between the current geometric center and the position provided, so that the relative positions of the vertices are preserved. Both image and world coordinates are accepted, but both values must match. @@ -1114,10 +1114,35 @@ def set_center(self, center): center : {0} The new center position. """ - [new] = self.region_set._from_world_coordinates([center]) - new = Pt(*new) - old = Pt(*self.center) - new_vertices = [(Pt(*v).x + new.x - old.x, Pt(*v).y + new.y - old.y) for v in self.vertices] + [(nx, ny)] = self.region_set._from_world_coordinates([center]) + cx, cy = self.center + new_vertices = [(vx + nx - cx, vy + ny - cy) for (vx, vy) in self.vertices] + self.set_vertices(new_vertices) + + @validate(Number()) + def rotate(self, rotation): + """Rotate this region. + + Polygonal and polyline regions do not store a separate rotation property, and cannot be rotated natively. The rotation provided will be applied to each vertex relative to the geometric center of the region. + + Parameters + ---------- + rotation : {0} + The rotation to apply, in degrees. + """ + cx, cy = self.center + deltas = [(x - cx, y - cy) for (x, y) in self.vertices] + + rot = math.radians(rotation) + sin_rot, cos_rot = math.sin(rot), math.cos(rot) + + new_vertices = [] + + for (dx, dy) in deltas: + x = cx + dx * cos_rot - dy * sin_rot + y = cy + dy * cos_rot + dx * sin_rot + new_vertices.append((x, y)) + self.set_vertices(new_vertices) @@ -1209,7 +1234,7 @@ def set_length(self, length): if isinstance(length, str): length = self.length * AngularSize.from_string(length).arcsec / AngularSize.from_string(self.wcs_length).arcsec - rad = self.rad_rotation + rad = math.radians(self.rotation) self.set_size((length * math.sin(rad), -1 * length * math.cos(rad))) @@ -1333,13 +1358,17 @@ class LineRegion(HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): # CONVERSION - @validate(NoneOr(Boolean())) - def as_polyline(self, delete=False): - """Return this region or annotation as a polyline. + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def as_polyline(self, oversampling=False, density=10, delete=False): + """Return this line region or annotation as a polyline. Parameters ---------- - delete : {0} + oversampling : {0} + Whether to add more vertices to the polyline, using the configured per-degree density. By default the polyline will only have three vertices: the endpoints and the center. + density : {1} + The approximate number of vertices to add per degree of the line length (the default is 10). Vertices will divide the line into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} Whether to delete the original region. Returns @@ -1349,9 +1378,23 @@ def as_polyline(self, delete=False): """ # The endpoints of a line have the rotation already applied, so we can use them as-is - start, end = self.endpoints - # We MUST include a third point (for now) because the frontend requires polylines to have at least 3 points - points = [start, self.center, end] + (sx, sy), (ex, ey) = self.endpoints + + # Number of points excluding the end point + # The frontend requires polylines to have at least 3 points, so we *must* include the center (for now) + num_points = 2 + + if oversampling: + # Adjust to approximate points per degree + arcsec_l = AngularSize.from_string(self.wcs_length).arcsec + num_points = max(round(arcsec_l * density / 3600), 2) + + # Plot points on line + points = [] + dx, dy = (ex - sx) / num_points, (ey - sy) / num_points + + for i in range(num_points + 1): + points.append((sx + i * dx, sy + i * dy)) polygon = self.region_set.add_polyline(points, annotation=(self.region_type == RegionType.ANNLINE), name=self.name) polygon.set_color(self.color) @@ -1379,6 +1422,32 @@ class RectangularRegion(HasRotationMixin, HasSizeMixin, Region): REGION_TYPES = (RegionType.RECTANGLE, RegionType.ANNRECTANGLE) """The region types corresponding to this class.""" + @staticmethod + def _center_size_from_corners(bottom_left, top_right): + """Internal utility function for calculating a center point and a size from a bottom-left and top-right corner. + + The corner points provided must be in image coordinates, and the returned center and size values are in image coordinates and pixel sizes, respectively. + + Parameters + ---------- + bottom_left : pair of numbers + The bottom-left corner in image coordinates. + top_right : pair of numbers + The top-right corner in image coordinates. + + Returns + ------- + pair of numbers + The center point in image coordinates. + pair of numbers + The size in pixels. + """ + bl_x, bl_y = bottom_left + tr_x, tr_y = top_right + w, h = tr_x - bl_x, tr_y - bl_y + center = w / 2 + bl_x, h / 2 + bl_y + return center, (w, h) + # GET PROPERTIES @property @@ -1390,10 +1459,10 @@ def corners(self): iterable containing two tuples of two numbers The bottom-left and top-right corner positions, in image coordinates. """ - center = Pt(*self.center) - size = Pt(*self.size) - dx, dy = size.x / 2, size.y / 2 - return ((center.x - dx, center.y - dy), (center.x + dx, center.y + dy)) + cx, cy = self.center + w, h = self.size + dx, dy = w / 2, h / 2 + return ((cx - dx, cy - dy), (cx + dx, cy + dy)) @property def wcs_corners(self): @@ -1441,23 +1510,22 @@ def set_corners(self, bottom_left=None, top_right=None): else: top_right = current_top_right - bl = Pt(*bottom_left) - tr = Pt(*top_right) - - size = Pt(tr.x - bl.x, tr.y - bl.y) - center = (bl.x + (size.x / 2), bl.y + (size.y / 2)) - - self.set_control_points([center, size.as_tuple()]) + center, size = self._center_size_from_corners(bottom_left, top_right) + self.set_control_points([center, size]) # CONVERSION - @validate(NoneOr(Boolean())) - def as_polygon(self, delete=False): - """Return this region or annotation as a polygon. + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def as_polygon(self, oversampling=False, density=10, delete=False): + """Return this rectangle region or annotation as a polygon. Parameters ---------- - delete : {0} + oversampling : {0} + Whether to add vertices to the sides of the rectangle, using the configured per-degree density. By default the polygon will only have four vertices at the corners. + density : {1} + The approximate number of vertices to add per degree of the rectangle perimeter (the default is 10). Vertices will divide each face into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} Whether to delete the original region. Returns @@ -1466,24 +1534,48 @@ def as_polygon(self, delete=False): A new region object. """ - center = Pt(*self.center) + cx, cy = self.center w, h = self.size - rot = self.rad_rotation + rot = math.radians(self.rotation) - deltas = ( + # Number of points per face (just starting corner by default) + num_points_x = 1 + num_points_y = 1 + + if oversampling: + # Adjust to approximate points per degree + arcsec_w, arcsec_h = (AngularSize.from_string(s).arcsec for s in self.wcs_size) + num_points_x = max(round(arcsec_w * density / 3600), 1) + num_points_y = max(round(arcsec_h * density / 3600), 1) + + # The four corners relative to the center, before rotation + corners = ( (-w / 2, -h / 2), (-w / 2, h / 2), (w / 2, h / 2), (w / 2, -h / 2), ) + # Number of points per face, clockwise from bottom left corner + face_points = (num_points_y, num_points_x, num_points_y, num_points_x) + + # All points relative to the center + deltas = [] + + # Plot points on faces (each face includes the start corner and excludes the end corner) + for (sx, sy), (ex, ey), num in zip(corners, corners[1:] + corners[:1], face_points): + dx, dy = (ex - sx) / num, (ey - sy) / num + for i in range(num): + deltas.append((sx + i * dx, sy + i * dy)) + + # The final points, after rotation and translation points = [] sin_rot, cos_rot = math.sin(rot), math.cos(rot) for (dx, dy) in deltas: - x = center.x + dx * cos_rot - dy * sin_rot - y = center.y + dy * cos_rot + dx * sin_rot + x = cx + dx * cos_rot - dy * sin_rot + y = cy + dy * cos_rot + dx * sin_rot points.append((x, y)) polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNRECTANGLE), name=self.name) @@ -1600,16 +1692,16 @@ def set_size(self, size): # CONVERSION @validate(*all_optional(Number(min=4), Number(min=0, interval=Number.EXCLUDE), Boolean())) - def as_polygon(self, num_vertices=None, vertices_per_degree=10, delete=False): - """Return a polygon approximation of this region or annotation. + def as_polygon(self, num_vertices=None, density=10, delete=False): + """Return a polygon approximation of this ellipse region or annotation. By default, the number of vertices to use for the approximation is derived from the angular size of the ellipse circumference and the configured number of vertices per degree, with a minimum of 12. Vertices will be distributed more densely near the major axis and more sparsely near the minor axis. Parameters ---------- num_vertices : {0} - The number of vertices to use. If this parameter is not provided, the number is generated dynamically. - vertices_per_degree : {1} + The number of vertices to use. If this parameter is not provided, the number is calculated dynamically from the configured density and the region size. + density : {1} The approximate number of vertices to add per degree of the ellipse circumference (the default is 10). This parameter is ignored if an exact number of vertices is provided. delete : {2} Whether to delete the original region. @@ -1619,16 +1711,16 @@ def as_polygon(self, num_vertices=None, vertices_per_degree=10, delete=False): :obj:`carta.region.PolygonRegion` object A new region object. """ - center = Pt(*self.center) + cx, cy = self.center b, a = self.semi_axes - rot = self.rad_rotation + rot = math.radians(self.rotation) if num_vertices is None: # Semi-axes in arcseconds wcs_b, wcs_a = (AngularSize.from_string(ax).arcsec for ax in self.wcs_semi_axes) perimeter = math.pi * (3 * (wcs_a + wcs_b) - math.sqrt((3 * wcs_a + wcs_b) * (3 * wcs_b + wcs_a))) # Approximately 10 vertices per degree (minimum: 12) - num_vertices = max(round(vertices_per_degree * perimeter / 3600), 12) + num_vertices = max(round(density * perimeter / 3600), 12) angles = [i * 2 * math.pi / num_vertices for i in range(num_vertices)] @@ -1638,8 +1730,8 @@ def as_polygon(self, num_vertices=None, vertices_per_degree=10, delete=False): for theta in angles: rot_a = a * math.cos(theta) rot_b = b * math.sin(theta) - x = center.x + cos_rot * rot_a - sin_rot * rot_b - y = center.y + sin_rot * rot_a + cos_rot * rot_b + x = cx + cos_rot * rot_a - sin_rot * rot_b + y = cy + sin_rot * rot_a + cos_rot * rot_b points.append((x, y)) polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNELLIPSE), name=self.name) diff --git a/tests/test_region.py b/tests/test_region.py index 73e7648..c9e9054 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -272,7 +272,7 @@ def test_region_type(image, get_value): assert region_type == RT.RECTANGLE -@pytest.mark.parametrize("region_type", {t for t in RT}) +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) def test_center(region, get_value, region_type): reg = region(region_type) reg_get_value = get_value(reg, {"x": 20, "y": 30}) @@ -283,6 +283,16 @@ def test_center(region, get_value, region_type): assert center == (20, 30) +@pytest.mark.parametrize("region_type", {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +def test_center_poly(region, method, property_, region_type): + reg = region(region_type) + property_(reg)("vertices", [(10, 20), (20, 40), (30, 30)]) + + center = reg.center + + assert center == (20, 30) + + @pytest.mark.parametrize("region_type", {t for t in RT}) def test_wcs_center(region, property_, mock_to_world, region_type): reg = region(region_type) From 5ad784307fc1a738d43644a4f7c0937dcefa39ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 18 Nov 2025 14:02:38 +0200 Subject: [PATCH 47/50] Also copy the line style to the polygon/polyline. Added to_poly* wrappers to as_poly*, which delete by default. Revise docstrings. --- carta/region.py | 93 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 7 deletions(-) diff --git a/carta/region.py b/carta/region.py index afe9f3e..002781e 100644 --- a/carta/region.py +++ b/carta/region.py @@ -1360,7 +1360,9 @@ class LineRegion(HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) def as_polyline(self, oversampling=False, density=10, delete=False): - """Return this line region or annotation as a polyline. + """Create a new polyline region with the same dimensions and style as this line region or annotation. + + This function does not delete the original region by default. Also see :obj:`carta.region.LineRegion.to_polyline`. Parameters ---------- @@ -1396,13 +1398,37 @@ def as_polyline(self, oversampling=False, density=10, delete=False): for i in range(num_points + 1): points.append((sx + i * dx, sy + i * dy)) - polygon = self.region_set.add_polyline(points, annotation=(self.region_type == RegionType.ANNLINE), name=self.name) - polygon.set_color(self.color) + polyline = self.region_set.add_polyline(points, annotation=(self.region_type == RegionType.ANNLINE), name=self.name) + + polyline.set_color(self.color) + polyline.set_line_style(self.line_width, self.dash_length) if delete: self.delete() - return polygon + return polyline + + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def to_polyline(self, oversampling=False, density=10, delete=True): + """Create a new polyline region with the same dimensions and style as this line region or annotation. + + This function is a convenience wrapper around :obj:`carta.region.LineRegion.as_polyline` that deletes the original region by default. + + Parameters + ---------- + oversampling : {0} + Whether to add more vertices to the polyline, using the configured per-degree density. By default the polyline will only have three vertices: the endpoints and the center. + density : {1} + The approximate number of vertices to add per degree of the line length (the default is 10). Vertices will divide the line into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolylineRegion` object + A new region object. + """ + return self.as_polyline(oversampling, density, delete) class PolylineRegion(HasVerticesMixin, HasSizeMixin, Region): @@ -1517,7 +1543,9 @@ def set_corners(self, bottom_left=None, top_right=None): @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) def as_polygon(self, oversampling=False, density=10, delete=False): - """Return this rectangle region or annotation as a polygon. + """Create a new polygonal region with the same dimensions and style as this rectangular region or annotation. + + This function does not delete the original region by default. Also see :obj:`carta.region.RectangularRegion.to_polygon`. Parameters ---------- @@ -1533,7 +1561,6 @@ def as_polygon(self, oversampling=False, density=10, delete=False): :obj:`carta.region.PolygonRegion` object A new region object. """ - cx, cy = self.center w, h = self.size rot = math.radians(self.rotation) @@ -1579,13 +1606,37 @@ def as_polygon(self, oversampling=False, density=10, delete=False): points.append((x, y)) polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNRECTANGLE), name=self.name) + polygon.set_color(self.color) + polygon.set_line_style(self.line_width, self.dash_length) if delete: self.delete() return polygon + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def to_polygon(self, oversampling=False, density=10, delete=True): + """Create a new polygonal region with the same dimensions and style as this rectangular region or annotation. + + This function is a convenience wrapper around :obj:`carta.region.RectangularRegion.as_polygon` that deletes the original region by default. + + Parameters + ---------- + oversampling : {0} + Whether to add vertices to the sides of the rectangle, using the configured per-degree density. By default the polygon will only have four vertices at the corners. + density : {1} + The approximate number of vertices to add per degree of the rectangle perimeter (the default is 10). Vertices will divide each face into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + return self.as_polygon(oversampling, density, delete) + class EllipticalRegion(HasRotationMixin, HasSizeMixin, Region): """An elliptical region or annotation.""" @@ -1693,7 +1744,9 @@ def set_size(self, size): @validate(*all_optional(Number(min=4), Number(min=0, interval=Number.EXCLUDE), Boolean())) def as_polygon(self, num_vertices=None, density=10, delete=False): - """Return a polygon approximation of this ellipse region or annotation. + """Create a new polygonal region with the same dimensions and style as this elliptical region or annotation, approximating the shape of the ellipse. + + This function does not delete the original region by default. Also see :obj:`carta.region.EllipticalRegion.to_polygon`. By default, the number of vertices to use for the approximation is derived from the angular size of the ellipse circumference and the configured number of vertices per degree, with a minimum of 12. Vertices will be distributed more densely near the major axis and more sparsely near the minor axis. @@ -1735,13 +1788,39 @@ def as_polygon(self, num_vertices=None, density=10, delete=False): points.append((x, y)) polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNELLIPSE), name=self.name) + polygon.set_color(self.color) + polygon.set_line_style(self.line_width, self.dash_length) if delete: self.delete() return polygon + @validate(*all_optional(Number(min=4), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def to_polygon(self, num_vertices=None, density=10, delete=True): + """Create a new polygonal region with the same dimensions and style as this elliptical region or annotation, approximating the shape of the ellipse. + + This function is a convenience wrapper around :obj:`carta.region.EllipticalRegion.as_polygon` that deletes the original region by default. + + By default, the number of vertices to use for the approximation is derived from the angular size of the ellipse circumference and the configured number of vertices per degree, with a minimum of 12. Vertices will be distributed more densely near the major axis and more sparsely near the minor axis. + + Parameters + ---------- + num_vertices : {0} + The number of vertices to use. If this parameter is not provided, the number is calculated dynamically from the configured density and the region size. + density : {1} + The approximate number of vertices to add per degree of the ellipse circumference (the default is 10). This parameter is ignored if an exact number of vertices is provided. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + return self.as_polygon(num_vertices, density, delete) + class PointAnnotation(Region): """A point annotation.""" From 51a1808c6345b74fd0eac44fc0347d8f9ad3a8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 24 Nov 2025 16:28:29 +0200 Subject: [PATCH 48/50] Added unit tests for translate / rotate functions --- carta/region.py | 4 ++-- tests/test_region.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/carta/region.py b/carta/region.py index 002781e..7ac65b4 100644 --- a/carta/region.py +++ b/carta/region.py @@ -986,7 +986,7 @@ def set_rotation(self, angle): @validate(Number()) def rotate(self, rotation): - """Rotate this region. + """Rotate this region anticlockwise by the angle provided. The rotation provided will be added to the current rotation of the region. @@ -1121,7 +1121,7 @@ def set_center(self, center): @validate(Number()) def rotate(self, rotation): - """Rotate this region. + """Rotate this region anticlockwise by the angle provided. Polygonal and polyline regions do not store a separate rotation property, and cannot be rotated natively. The rotation provided will be applied to each vertex relative to the geometric center of the region. diff --git a/tests/test_region.py b/tests/test_region.py index c9e9054..4785d91 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -398,6 +398,21 @@ def test_set_center_poly(region, mock_from_world, method, property_, region_type mock_set_vertices.assert_called_with(expected_value) +@pytest.mark.parametrize("region_type", {t for t in RT}) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), (40, 50)), + (("20", "30"), (40, 50)), +]) +def test_translate(region, mock_from_world, mock_from_angular, method, property_, region_type, value, expected_value): + reg = region(region_type) + property_(reg)("center", (20, 20)) + mock_set_center = method(reg)("set_center", None) + + reg.translate(value) + + mock_set_center.assert_called_with(expected_value) + + @pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) @pytest.mark.parametrize("value,expected_value", [ ((20, 30), Pt(20, 30)), @@ -549,6 +564,30 @@ def test_set_rotation(region, call_action, region_type): mock_call.assert_called_with("setRotation", 45) +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) +def test_rotate(region, method, property_, region_type): + reg = region(region_type) + property_(reg)("rotation", 45) + mock_set_rotation = method(reg)("set_rotation", None) + + reg.rotate(10) + + mock_set_rotation.assert_called_with(55) + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +def test_rotate_poly(region, method, property_, region_type): + reg = region(region_type) + property_(reg)("vertices", [(10, 20), (20, 40), (30, 30)]) + property_(reg)("center", (20, 30)) + mock_set_vertices = method(reg)("set_vertices", None) + + reg.rotate(90) + + # Rotation is applied anti-clockwise. + mock_set_vertices.assert_called_with([(30.0, 20.0), (10.0, 30.0), (20.0, 40.0)]) + + @pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) def test_vertices(region, property_, region_type): reg = region(region_type) From d9054f7a318032a28c27992dda765fbac55404ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Mon, 24 Nov 2025 18:44:45 +0200 Subject: [PATCH 49/50] Added unit tests for polygon/line conversions --- tests/test_region.py | 139 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/test_region.py b/tests/test_region.py index 4785d91..38e0820 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -272,6 +272,145 @@ def test_region_type(image, get_value): assert region_type == RT.RECTANGLE +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +@pytest.mark.parametrize("func,args,kwargs,expected_vertices,expected_delete", ( + # Corners only + # (Note: apparent brc is actual blc because rectangle is rotated) + ("as_polygon", [], {}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], False), + # Corners only + delete + ("as_polygon", [], {"delete": True}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], True), + # Corners only + alias that deletes + ("to_polygon", [], {}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], True), + # Oversampling; default density of 10 vertices per degree; just enough for 1 extra vertex per long side + ("as_polygon", [], {"oversampling": True}, [(45.0, 10.0), (20.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (20.0, 50.0), (45.0, 50.0)], False), + # Oversampling; density of 20 vertices per degree; 3 extra per long side + 1 extra per short side + ("as_polygon", [], {"oversampling": True, "density": 20}, [(45.0, 10.0), (32.5, 10.0), (20.0, 10.0), (7.5, 10.0), (-5.0, 10.0), (-5.0, 30.0), (-5.0, 50.0), (7.5, 50.0), (20.0, 50.0), (32.5, 50.0), (45.0, 50.0), (45.0, 30.0)], False), + # Oversampling; very low density; won't drop below 4 corners + ("as_polygon", [], {"oversampling": True, "density": 1}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], False), +)) +def test_rectangle_to_polygon(mocker, regionset_method, region, property_, method, region_type, func, args, kwargs, expected_vertices, expected_delete): + mock_add_polygon = regionset_method("add_polygon", None) + + reg = region(region_type) + property_(reg)("center", (20, 30)) + property_(reg)("size", (40, 50)) + property_(reg)("wcs_size", ("0.1deg", "0.2deg")) + property_(reg)("rotation", 90) + property_(reg)("name", "something") + property_(reg)("color", "green") + property_(reg)("line_width", 1) + property_(reg)("dash_length", 2) + mock_delete = method(reg)("delete", None) + + polygon = region(region_type=RT.POLYGON) + mock_add_polygon.return_value = polygon + mock_set_color = mocker.patch.object(polygon, "set_color") + mock_set_line_style = mocker.patch.object(polygon, "set_line_style") + + getattr(reg, func)(*args, **kwargs) + + mock_add_polygon.assert_called_with(expected_vertices, annotation=(region_type == RT.ANNRECTANGLE), name="something") + mock_set_color.assert_called_with("green") + mock_set_line_style.assert_called_with(1, 2) + + if expected_delete: + mock_delete.assert_called() + else: + mock_delete.assert_not_called() + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE}) +@pytest.mark.parametrize("func,args,kwargs,expected_vertices,expected_delete", ( + # Endpoints and midpoint only + ("as_polyline", [], {}, [(10, 10), (20, 20), (30, 30)], False), + # Endpoints and midpoint only + delete + ("as_polyline", [], {"delete": True}, [(10, 10), (20, 20), (30, 30)], True), + # Endpoints and midpoint only + alias that deletes + ("to_polyline", [], {}, [(10, 10), (20, 20), (30, 30)], True), + # Oversampling; default density of 10 vertices per degree - 5 segments + ("as_polyline", [], {"oversampling": True}, [(10.0, 10.0), (14.0, 14.0), (18.0, 18.0), (22.0, 22.0), (26.0, 26.0), (30.0, 30.0)], False), + # Oversampling; density of 20 vertices per degree - 10 segments + ("as_polyline", [], {"oversampling": True, "density": 20}, [(10.0, 10.0), (12.0, 12.0), (14.0, 14.0), (16.0, 16.0), (18.0, 18.0), (20.0, 20.0), (22.0, 22.0), (24.0, 24.0), (26.0, 26.0), (28.0, 28.0), (30.0, 30.0)], False), + # Oversampling; very low density; won't fall below 3 vertices + ("as_polyline", [], {"oversampling": True, "density": 1}, [(10, 10), (20, 20), (30, 30)], False), +)) +def test_line_to_polyline(mocker, regionset_method, region, property_, method, region_type, func, args, kwargs, expected_vertices, expected_delete): + mock_add_polyline = regionset_method("add_polyline", None) + + reg = region(region_type) + + property_(reg)("endpoints", [(10, 10), (30, 30)]) + property_(reg)("wcs_length", "0.5deg") + property_(reg)("name", "something") + property_(reg)("color", "green") + property_(reg)("line_width", 1) + property_(reg)("dash_length", 2) + mock_delete = method(reg)("delete", None) + + polyline = region(region_type=RT.POLYLINE) + mock_add_polyline.return_value = polyline + mock_set_color = mocker.patch.object(polyline, "set_color") + mock_set_line_style = mocker.patch.object(polyline, "set_line_style") + + getattr(reg, func)(*args, **kwargs) + + mock_add_polyline.assert_called_with(expected_vertices, annotation=(region_type == RT.ANNLINE), name="something") + mock_set_color.assert_called_with("green") + mock_set_line_style.assert_called_with(1, 2) + + if expected_delete: + mock_delete.assert_called() + else: + mock_delete.assert_not_called() + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +@pytest.mark.parametrize("func,args,kwargs,expected_num_vertices,expected_delete", ( + # Specific number of vertices + ("as_polygon", [], {"num_vertices": 8}, 8, False), + # Specific number of vertices + delete + ("as_polygon", [], {"num_vertices": 8, "delete": True}, 8, True), + # Specific number of vertices + alias that deletes + ("to_polygon", [], {"num_vertices": 8}, 8, True), + # Default density of 10 vertices per degree; perimeter approx. 2 deg -> 20 vertices + ("as_polygon", [], {}, 20, False), + # Density of 20 vertices per degree -> 40 vertices + ("as_polygon", [], {"density": 20}, 40, False), + # Density of 5 vertices per degree -> would be 10, but minimum is 12 + ("as_polygon", [], {"density": 5}, 12, False), +)) +def test_ellipse_to_polygon(mocker, regionset_method, region, property_, method, region_type, func, args, kwargs, expected_num_vertices, expected_delete): + mock_add_polygon = regionset_method("add_polygon", None) + + reg = region(region_type) + property_(reg)("center", (20, 30)) + property_(reg)("semi_axes", (20, 25)) + property_(reg)("wcs_semi_axes", ("0.2deg", "0.42deg")) + property_(reg)("rotation", 45) + property_(reg)("name", "something") + property_(reg)("color", "green") + property_(reg)("line_width", 1) + property_(reg)("dash_length", 2) + mock_delete = method(reg)("delete", None) + + polygon = region(region_type=RT.POLYGON) + mock_add_polygon.return_value = polygon + mock_set_color = mocker.patch.object(polygon, "set_color") + mock_set_line_style = mocker.patch.object(polygon, "set_line_style") + + getattr(reg, func)(*args, **kwargs) + + mock_add_polygon.assert_called_with(mocker.ANY, annotation=(region_type == RT.ANNELLIPSE), name="something") + assert len(mock_add_polygon.call_args.args[0]) == expected_num_vertices + mock_set_color.assert_called_with("green") + mock_set_line_style.assert_called_with(1, 2) + + if expected_delete: + mock_delete.assert_called() + else: + mock_delete.assert_not_called() + + @pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) def test_center(region, get_value, region_type): reg = region(region_type) From 673efdd6153a347e6e65dd8b3ff266b91385474e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrianna=20Pi=C5=84ska?= Date: Tue, 25 Nov 2025 11:07:01 +0200 Subject: [PATCH 50/50] reduce depth of table of contents --- docs/source/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 0831f18..6d4c9fa 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -2,7 +2,7 @@ carta-python: a scripting wrapper for CARTA =========================================== .. toctree:: - :maxdepth: 4 + :maxdepth: 2 :caption: Contents: introduction