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