diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index d635b72b8ae0..ff2ea64654a0 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -1,5 +1,6 @@ import io from typing import ( + Any, List, Optional, Tuple, @@ -7,6 +8,8 @@ Union, ) +from ._util import _assert_number + try: import numpy except ImportError: @@ -44,28 +47,79 @@ def _assert_float( assert actual <= float(range_max), f"Wrong {label}: {actual} (must be {range_max} or smaller)" -def assert_image_has_metadata( +def assert_has_image_width( output_bytes: bytes, - width: Optional[Union[int, str]] = None, - height: Optional[Union[int, str]] = None, - channels: Optional[Union[int, str]] = None, + value: Optional[Union[int, str]] = None, + delta: Union[int, str] = 0, + min: Optional[Union[int, str]] = None, + max: Optional[Union[int, str]] = None, + negate: Union[bool, str] = False, ) -> None: """ - Assert the image output has specific metadata. + Asserts the specified output is an image and has a width of the specified value. """ buf = io.BytesIO(output_bytes) with Image.open(buf) as im: + _assert_number( + im.size[0], + value, + delta, + min, + max, + negate, + "{expected} width {n}+-{delta}", + "{expected} width to be in [{min}:{max}]", + ) + - assert width is None or im.size[0] == int(width), f"Image has wrong width: {im.size[0]} (expected {int(width)})" +def assert_has_image_height( + output_bytes: bytes, + value: Optional[Union[int, str]] = None, + delta: Union[int, str] = 0, + min: Optional[Union[int, str]] = None, + max: Optional[Union[int, str]] = None, + negate: Union[bool, str] = False, +) -> None: + """ + Asserts the specified output is an image and has a height of the specified value. + """ + buf = io.BytesIO(output_bytes) + with Image.open(buf) as im: + _assert_number( + im.size[1], + value, + delta, + min, + max, + negate, + "{expected} height {n}+-{delta}", + "{expected} height to be in [{min}:{max}]", + ) - assert height is None or im.size[1] == int( - height - ), f"Image has wrong height: {im.size[1]} (expected {int(height)})" - actual_channels = len(im.getbands()) - assert channels is None or actual_channels == int( - channels - ), f"Image has wrong number of channels: {actual_channels} (expected {int(channels)})" +def assert_has_image_channels( + output_bytes: bytes, + value: Optional[Union[int, str]] = None, + delta: Union[int, str] = 0, + min: Optional[Union[int, str]] = None, + max: Optional[Union[int, str]] = None, + negate: Union[bool, str] = False, +) -> None: + """ + Asserts the specified output is an image and has the specified number of channels. + """ + buf = io.BytesIO(output_bytes) + with Image.open(buf) as im: + _assert_number( + len(im.getbands()), + value, + delta, + min, + max, + negate, + "{expected} image channels {n}+-{delta}", + "{expected} image channels to be in [{min}:{max}]", + ) def _compute_center_of_mass(im_arr: "numpy.typing.NDArray") -> Tuple[float, float]: @@ -79,17 +133,12 @@ def _compute_center_of_mass(im_arr: "numpy.typing.NDArray") -> Tuple[float, floa return (im_arr * xx).sum(), (im_arr * yy).sum() -def assert_image_has_intensities( +def _get_image( output_bytes: bytes, channel: Optional[Union[int, str]] = None, - mean_intensity: Optional[Union[float, str]] = None, - mean_intensity_min: Optional[Union[float, str]] = None, - mean_intensity_max: Optional[Union[float, str]] = None, - center_of_mass: Optional[Union[Tuple[float, float], str]] = None, - eps: Union[float, str] = 0.01, -) -> None: +) -> "numpy.typing.NDArray": """ - Assert the image output has specific intensity content. + Returns the output image or a specific channel. """ buf = io.BytesIO(output_bytes) with Image.open(buf) as im: @@ -99,79 +148,143 @@ def assert_image_has_intensities( if channel is not None: im_arr = im_arr[:, :, int(channel)] - # Perform `mean_intensity` assertions. + # Return the image + return im_arr + + +def assert_has_image_mean_intensity( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, + value: Optional[Union[float, str]] = None, + delta: Union[float, str] = 0.01, + min: Optional[Union[float, str]] = None, + max: Optional[Union[float, str]] = None, +) -> None: + """ + Asserts the specified output is an image and has the specified mean intensity value. + """ + im_arr = _get_image(output_bytes, channel) _assert_float( actual=im_arr.mean(), label="mean intensity", - tolerance=eps, - expected=mean_intensity, - range_min=mean_intensity_min, - range_max=mean_intensity_max, + tolerance=delta, + expected=value, + range_min=min, + range_max=max, ) - # Perform `center_of_mass` assertion. - if center_of_mass is not None: - if isinstance(center_of_mass, str): - center_of_mass_parts = [c.strip() for c in center_of_mass.split(",")] - assert len(center_of_mass_parts) == 2 - center_of_mass = (float(center_of_mass_parts[0]), float(center_of_mass_parts[1])) - assert len(center_of_mass) == 2, "center_of_mass must have two components" + +def assert_has_image_center_of_mass( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, + point: Optional[Union[Tuple[float, float], str]] = None, + delta: Union[float, str] = 0.01, +) -> None: + """ + Asserts the specified output is an image and has the specified center of mass. + """ + im_arr = _get_image(output_bytes, channel) + if point is not None: + if isinstance(point, str): + point_parts = [c.strip() for c in point.split(",")] + assert len(point_parts) == 2 + point = (float(point_parts[0]), float(point_parts[1])) + assert len(point) == 2, "point must have two components" actual_center_of_mass = _compute_center_of_mass(im_arr) - distance = numpy.linalg.norm(numpy.subtract(center_of_mass, actual_center_of_mass)) + distance = numpy.linalg.norm(numpy.subtract(point, actual_center_of_mass)) assert distance <= float( - eps - ), f"Wrong center of mass: {actual_center_of_mass} (expected {center_of_mass}, distance: {distance}, eps: {eps})" + delta + ), f"Wrong center of mass: {actual_center_of_mass} (expected {point}, distance: {distance}, delta: {delta})" -def assert_image_has_labels( +def _get_image_labels( output_bytes: bytes, - number_of_objects: Optional[Union[int, str]] = None, - mean_object_size: Optional[Union[float, str]] = None, - mean_object_size_min: Optional[Union[float, str]] = None, - mean_object_size_max: Optional[Union[float, str]] = None, + channel: Optional[Union[int, str]] = None, + labels: Optional[Union[str, List[int]]] = None, exclude_labels: Optional[Union[str, List[int]]] = None, - eps: Union[float, str] = 0.01, -) -> None: +) -> Tuple["numpy.typing.NDArray", List[Any]]: """ - Assert the image output has specific label content. + Determines the unique labels in the output image or a specific channel. """ - buf = io.BytesIO(output_bytes) - with Image.open(buf) as im: - im_arr = numpy.array(im) + assert labels is None or exclude_labels is None + im_arr = _get_image(output_bytes, channel) + + def cast_label(label): + label = label.strip() + if numpy.issubdtype(im_arr.dtype, numpy.integer): + return int(label) + if numpy.issubdtype(im_arr.dtype, float): + return float(label) + raise AssertionError(f'Unsupported image label type: "{im_arr.dtype}"') # Determine labels present in the image. - labels = numpy.unique(im_arr) + present_labels = numpy.unique(im_arr) + + # Apply filtering due to `labels` (keep only those). + if labels is None: + labels = list() + if isinstance(labels, str): + labels = [cast_label(label) for label in labels.split(",") if len(label) > 0] + if len(labels) > 0: + present_labels = [label for label in present_labels if label in labels] # Apply filtering due to `exclude_labels`. if exclude_labels is None: exclude_labels = list() if isinstance(exclude_labels, str): + exclude_labels = [cast_label(label) for label in exclude_labels.split(",") if len(label) > 0] + present_labels = [label for label in present_labels if label not in exclude_labels] - def cast_label(label): - if numpy.issubdtype(im_arr.dtype, numpy.integer): - return int(label) - if numpy.issubdtype(im_arr.dtype, float): - return float(label) - raise AssertionError(f'Unsupported image label type: "{im_arr.dtype}"') + # Return the image data and the labels. + return im_arr, present_labels - exclude_labels = [cast_label(label) for label in exclude_labels.split(",") if len(label) > 0] - labels = [label for label in labels if label not in exclude_labels] - - # Perform `number_of_objects` assertion. - if number_of_objects is not None: - actual_number_of_objects = len(labels) - expected_number_of_objects = int(number_of_objects) - assert ( - actual_number_of_objects == expected_number_of_objects - ), f"Wrong number of objects: {actual_number_of_objects} (expected {expected_number_of_objects})" - - # Perform `mean_object_size` assertion. - actual_mean_object_size = sum((im_arr == label).sum() for label in labels) / len(labels) + +def assert_has_image_labels( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, + exclude_labels: Optional[Union[str, List[int]]] = None, + value: Optional[Union[int, str]] = None, + delta: Union[int, str] = 0, + min: Optional[Union[int, str]] = None, + max: Optional[Union[int, str]] = None, + negate: Union[bool, str] = False, +) -> None: + """ + Asserts the specified output is an image and has the specified number of unique values (e.g., uniquely labeled objects). + """ + present_labels = _get_image_labels(output_bytes, channel, exclude_labels)[1] + _assert_number( + len(present_labels), + value, + delta, + min, + max, + negate, + "{expected} labels {n}+-{delta}", + "{expected} labels to be in [{min}:{max}]", + ) + + +def assert_has_image_mean_object_size( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, + labels: Optional[Union[str, List[int]]] = None, + exclude_labels: Optional[Union[str, List[int]]] = None, + value: Optional[Union[float, str]] = None, + delta: Union[float, str] = 0.01, + min: Optional[Union[float, str]] = None, + max: Optional[Union[float, str]] = None, +) -> None: + """ + Asserts the specified output is an image with labeled objects which have the specified mean size (number of pixels). + """ + im_arr, present_labels = _get_image_labels(output_bytes, channel, labels, exclude_labels) + actual_mean_object_size = sum((im_arr == label).sum() for label in present_labels) / len(present_labels) _assert_float( actual=actual_mean_object_size, label="mean object size", - tolerance=eps, - expected=mean_object_size, - range_min=mean_object_size_min, - range_max=mean_object_size_max, + tolerance=delta, + expected=value, + range_min=min, + range_max=max, ) diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 73a3e7f3e1ac..0d107df49ee0 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -2248,9 +2248,13 @@ module. - - - + + + + + + + @@ -2751,120 +2755,260 @@ $attribute_list::5 - + ``). +Alternatively the range of the expected width can be specified by ``min`` and/or ``max``. -```xml - -``` +$attribute_list::5 +]]> + + + + + Expected width of the image (in pixels).` + + + + + Maximum allowed difference of the image width (in pixels, default is 0). The observed width has to be in the range ``value +- delta``. + + + + + Minimum allowed width of the image (in pixels). + + + + + Maximum allowed width of the image (in pixels). + + + + + + + ``). +Alternatively the range of the expected height can be specified by ``min`` and/or ``max``. $attribute_list::5 ]]> - + + + Expected height of the image (in pixels).` + + + - The required width of the image. + Maximum allowed difference of the image height (in pixels, default is 0). The observed height has to be in the range ``value +- delta``. - + - The required height of the image. + Minimum allowed height of the image (in pixels). - + - The required number of channels of the image (e.g., 1 for grayscale, 3 for RGB, 4 for RGBA images). + Maximum allowed height of the image (in pixels). + - + ``). +Alternatively the range of the expected number of channels can be specified by ``min`` and/or ``max``. -```xml - -``` +$attribute_list::5 +]]> + + + + + Expected number of channels of the image.` + + + + + Maximum allowed difference of the number of channels (default is 0). The observed number of channels has to be in the range ``value +- delta``. + + + + + Minimum allowed number of channels. + + + + + Maximum allowed number of channels. + + + + + + + ``). +Alternatively the range of the expected mean intensity value can be specified by ``min`` and/or ``max``. $attribute_list::5 ]]> - + The required mean value of the image intensities. - + + + The absolute tolerance to be used for ``value`` (defaults to ``0.01``). The observed mean value of the image intensities has to be in the range ``value +- delta``. + + + A lower bound of the required mean value of the image intensities. - + An upper bound of the required mean value of the image intensities. - + + + Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + + + + + + ``). + +$attribute_list::5 +]]> + + + The required center of mass of the image intensities (horizontal and vertical coordinate, separated by a comma). - + - The absolute tolerance to be used for the ``mean_intensity`` and ``center_of_mass`` assertions (defaults to ``0.01``). + The maximum allowed Euclidean distance to the required center of mass (defaults to ``0.01``). - Restricts the ``mean_intensity``, ``mean_intensity_min``, ``mean_intensity_max``, and ``center_of_mass`` assertions to a specific channel of the image (where the value ``0`` corresponds to the first image channel). + Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). - + ``). +The primary usage of this assertion is to verify the number of objects in images with uniquely labeled objects. -```xml - -``` +$attribute_list::5 +]]> + + + + + Expected number of labels.` + + + + + Maximum allowed difference of the number of labels (default is 0). The observed number of labels has to be in the range ``value +- delta``. + + + + + Minimum allowed number of labels. + + + + + Maximum allowed number of labels. + + + + + Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). Must be used with multi-channel imags. + + + + + List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``. + + + + + List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``. + + + + + + + ``). The labels must be unique. $attribute_list::5 ]]> - + + + The required mean size of the uniquely labeled objects. + + + - The required number of unique labels in the image. It is assumed that each individual object corresponds to a unique label. + The absolute tolerance to be used for ``value`` (defaults to ``0.01``). The observed mean size of the uniquely labeled objects has to be in the range ``value +- delta``. - + - The required mean size of the objects in the image, where the size of an object is measured by the number of pixels. It is assumed that each individual object corresponds to a unique label. + A lower bound of the required mean size of the uniquely labeled objects. - + - A lower bound of the required mean size of the objects in the image, where the size of an object is measured by the number of pixels. It is assumed that each individual object corresponds to a unique label. + An upper bound of the required mean size of the uniquely labeled objects. - + - An upper bound of the required mean size of the objects in the image, where the size of an object is measured by the number of pixels. It is assumed that each individual object corresponds to a unique label. + Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). Must be used with multi-channel imags. - + - The absolute tolerance to be used for the ``mean_object_size`` assertion (defaults to 0.01). + List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``. - List of labels to be excluded from consideration for the ``number_of_objects`` and ``mean_object_size`` assertions, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. + List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``. diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index 421736f36ccd..840ffdfa7719 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -14,8 +14,10 @@ - - + + + + @@ -23,11 +25,15 @@ - - - - - + + + + + + + + + @@ -35,7 +41,7 @@ - + @@ -43,7 +49,7 @@ - + @@ -51,7 +57,7 @@ - + @@ -59,7 +65,7 @@ - + @@ -67,7 +73,7 @@ - + @@ -76,8 +82,11 @@ - - + + + + + @@ -85,8 +94,27 @@ - - + + + + + + + + + + + + + + + + + + + + + @@ -94,7 +122,7 @@ - + @@ -102,8 +130,11 @@ - - + + + + + @@ -111,7 +142,7 @@ - + @@ -119,7 +150,7 @@ - + @@ -127,7 +158,7 @@ - +