diff --git a/lib/galaxy/tool_util/verify/asserts/__init__.py b/lib/galaxy/tool_util/verify/asserts/__init__.py index 3d34e5bdb18d..10e0ce8909e4 100644 --- a/lib/galaxy/tool_util/verify/asserts/__init__.py +++ b/lib/galaxy/tool_util/verify/asserts/__init__.py @@ -11,7 +11,7 @@ log = logging.getLogger(__name__) -assertion_module_names = ["text", "tabular", "xml", "json", "hdf5", "archive", "size"] +assertion_module_names = ["text", "tabular", "xml", "json", "hdf5", "archive", "size", "image"] # Code for loading modules containing assertion checking functions, to # create a new module of assertion functions, create the needed python diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py new file mode 100644 index 000000000000..ec5fb01d52da --- /dev/null +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -0,0 +1,289 @@ +import io +from typing import ( + Any, + List, + Optional, + Tuple, + TYPE_CHECKING, + Union, +) + +from ._util import _assert_number + +try: + import numpy +except ImportError: + pass +try: + from PIL import Image +except ImportError: + pass + +if TYPE_CHECKING: + import numpy.typing + + +def _assert_float( + actual: float, + label: str, + tolerance: Union[float, str], + expected: Optional[Union[float, str]] = None, + range_min: Optional[Union[float, str]] = None, + range_max: Optional[Union[float, str]] = None, +) -> None: + + # Perform `tolerance` based check. + if expected is not None: + assert abs(actual - float(expected)) <= float( + tolerance + ), f"Wrong {label}: {actual} (expected {expected} ±{tolerance})" + + # Perform `range_min` based check. + if range_min is not None: + assert actual >= float(range_min), f"Wrong {label}: {actual} (must be {range_min} or larger)" + + # Perform `range_max` based check. + if range_max is not None: + assert actual <= float(range_max), f"Wrong {label}: {actual} (must be {range_max} or smaller)" + + +def assert_has_image_width( + output_bytes: bytes, + width: 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 width of the specified value. + """ + buf = io.BytesIO(output_bytes) + with Image.open(buf) as im: + _assert_number( + im.size[0], + width, + delta, + min, + max, + negate, + "{expected} width {n}+-{delta}", + "{expected} width to be in [{min}:{max}]", + ) + + +def assert_has_image_height( + output_bytes: bytes, + height: 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], + height, + delta, + min, + max, + negate, + "{expected} height {n}+-{delta}", + "{expected} height to be in [{min}:{max}]", + ) + + +def assert_has_image_channels( + output_bytes: bytes, + channels: 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()), + channels, + 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]: + while im_arr.ndim > 2: + im_arr = im_arr.sum(axis=2) + im_arr = numpy.abs(im_arr) + if im_arr.sum() == 0: + return (numpy.nan, numpy.nan) + im_arr = im_arr / im_arr.sum() + yy, xx = numpy.indices(im_arr.shape) + return (im_arr * xx).sum(), (im_arr * yy).sum() + + +def _get_image( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, +) -> "numpy.typing.NDArray": + """ + Returns the output image or a specific channel. + """ + buf = io.BytesIO(output_bytes) + with Image.open(buf) as im: + im_arr = numpy.array(im) + + # Select the specified channel (if any). + if channel is not None: + im_arr = im_arr[:, :, int(channel)] + + # Return the image + return im_arr + + +def assert_has_image_mean_intensity( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, + mean_intensity: Optional[Union[float, str]] = None, + eps: 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=min, + range_max=max, + ) + + +def assert_has_image_center_of_mass( + output_bytes: bytes, + center_of_mass: Union[Tuple[float, float], str], + channel: Optional[Union[int, str]] = None, + eps: 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 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" + actual_center_of_mass = _compute_center_of_mass(im_arr) + distance = numpy.linalg.norm(numpy.subtract(center_of_mass, 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})" + + +def _get_image_labels( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, + labels: Optional[Union[str, List[int]]] = None, + exclude_labels: Optional[Union[str, List[int]]] = None, +) -> Tuple["numpy.typing.NDArray", List[Any]]: + """ + Determines the unique labels in the output image or a specific channel. + """ + 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. + present_labels = numpy.unique(im_arr) + + # Apply filtering due to `labels` (keep only those). + if labels is None: + labels = [] + 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 = [] + 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] + + # Return the image data and the labels. + return im_arr, present_labels + + +def assert_has_image_n_labels( + output_bytes: bytes, + channel: Optional[Union[int, str]] = None, + exclude_labels: Optional[Union[str, List[int]]] = None, + n: 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), + n, + 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, + mean_object_size: Optional[Union[float, str]] = None, + eps: 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=min, + range_max=max, + ) diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index c4e6f1a8eea3..927c5d6cbb65 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -2178,6 +2178,7 @@ module. + @@ -2242,6 +2243,20 @@ module. + + + + + + + + + + + + + + @@ -2490,6 +2505,7 @@ $attribute_list::5 + @@ -2739,6 +2755,264 @@ $attribute_list::5 + + + ``). +Alternatively the range of the expected width can be specified by ``min`` and/or ``max``. + +$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).` + + + + + Maximum allowed difference of the image height (in pixels, default is 0). The observed height has to be in the range ``value +- delta``. + + + + + Minimum allowed height of the image (in pixels). + + + + + 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``. + +$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 +- eps``. + + + + + 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 maximum allowed Euclidean distance to the required center of mass (defaults to ``0.01``). + + + + + 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. + +$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 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 +- eps``. + + + + + A lower bound of the required mean size of the uniquely labeled objects. + + + + + An upper bound of the required mean size of the uniquely labeled objects. + + + + + 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``. + + + + diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml new file mode 100644 index 000000000000..852e78191a12 --- /dev/null +++ b/test/functional/tools/validation_image.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file