diff --git a/lib/galaxy/tool_util/verify/assertion_models.py b/lib/galaxy/tool_util/verify/assertion_models.py index 142c476ffdb6..4e184334e368 100644 --- a/lib/galaxy/tool_util/verify/assertion_models.py +++ b/lib/galaxy/tool_util/verify/assertion_models.py @@ -1452,6 +1452,12 @@ class has_size_model_nested(AssertionModel): has_image_center_of_mass_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_center_of_mass_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_center_of_mass_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" + has_image_center_of_mass_eps_description = ( """The maximum allowed Euclidean distance to the required center of mass (defaults to ``0.01``).""" ) @@ -1470,6 +1476,16 @@ class base_has_image_center_of_mass_model(AssertionModel): description=has_image_center_of_mass_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_center_of_mass_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_center_of_mass_frame_description, + ) + eps: Annotated[typing.Union[StrictInt, StrictFloat], BeforeValidator(check_non_negative_if_set)] = Field( 0.01, description=has_image_center_of_mass_eps_description, @@ -1548,6 +1564,116 @@ class has_image_channels_model_nested(AssertionModel): has_image_channels: base_has_image_channels_model +has_image_depth_depth_description = """Expected depth of the image (number of slices).""" + +has_image_depth_delta_description = """Maximum allowed difference of the image depth (number of slices, default is 0). The observed depth has to be in the range ``value +- delta``.""" + +has_image_depth_min_description = """Minimum allowed depth of the image (number of slices).""" + +has_image_depth_max_description = """Maximum allowed depth of the image (number of slices).""" + +has_image_depth_negate_description = """A boolean that can be set to true to negate the outcome of the assertion.""" + + +class base_has_image_depth_model(AssertionModel): + """base model for has_image_depth describing attributes.""" + + depth: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_depth_depth_description, + ) + + delta: Annotated[StrictInt, BeforeValidator(check_non_negative_if_set)] = Field( + 0, + description=has_image_depth_delta_description, + ) + + min: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_depth_min_description, + ) + + max: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_depth_max_description, + ) + + negate: typing.Union[bool, str] = Field( + False, + description=has_image_depth_negate_description, + ) + + +class has_image_depth_model(base_has_image_depth_model): + r"""Asserts the output is an image and has a specific depth (number of slices). + + The depth is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``.""" + + that: Literal["has_image_depth"] = "has_image_depth" + + +class has_image_depth_model_nested(AssertionModel): + r"""Nested version of this assertion model.""" + + has_image_depth: base_has_image_depth_model + + +has_image_frames_frames_description = """Expected number of frames in the image sequence (number of time steps).""" + +has_image_frames_delta_description = """Maximum allowed difference of the number of frames in the image sequence (number of time steps, default is 0). The observed number of frames has to be in the range ``value +- delta``.""" + +has_image_frames_min_description = """Minimum allowed number of frames in the image sequence (number of time steps).""" + +has_image_frames_max_description = """Maximum allowed number of frames in the image sequence (number of time steps).""" + +has_image_frames_negate_description = """A boolean that can be set to true to negate the outcome of the assertion.""" + + +class base_has_image_frames_model(AssertionModel): + """base model for has_image_frames describing attributes.""" + + frames: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_frames_frames_description, + ) + + delta: Annotated[StrictInt, BeforeValidator(check_non_negative_if_set)] = Field( + 0, + description=has_image_frames_delta_description, + ) + + min: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_frames_min_description, + ) + + max: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_frames_max_description, + ) + + negate: typing.Union[bool, str] = Field( + False, + description=has_image_frames_negate_description, + ) + + +class has_image_frames_model(base_has_image_frames_model): + r"""Asserts the output is an image and has a specific number of frames (number of time steps). + + The number of frames is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``.""" + + that: Literal["has_image_frames"] = "has_image_frames" + + +class has_image_frames_model_nested(AssertionModel): + r"""Nested version of this assertion model.""" + + has_image_frames: base_has_image_frames_model + + has_image_height_height_description = """Expected height of the image (in pixels).""" has_image_height_delta_description = """Maximum allowed difference of the image height (in pixels, default is 0). The observed height has to be in the range ``value +- delta``.""" @@ -1605,6 +1731,12 @@ class has_image_height_model_nested(AssertionModel): has_image_mean_intensity_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_mean_intensity_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_mean_intensity_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" + has_image_mean_intensity_mean_intensity_description = """The required mean value of the image intensities.""" has_image_mean_intensity_eps_description = """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``.""" @@ -1622,6 +1754,16 @@ class base_has_image_mean_intensity_model(AssertionModel): description=has_image_mean_intensity_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_intensity_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_intensity_frame_description, + ) + mean_intensity: typing.Optional[typing.Union[StrictInt, StrictFloat]] = Field( None, description=has_image_mean_intensity_mean_intensity_description, @@ -1660,6 +1802,12 @@ class has_image_mean_intensity_model_nested(AssertionModel): has_image_mean_object_size_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_mean_object_size_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_mean_object_size_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" + has_image_mean_object_size_labels_description = """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``.""" has_image_mean_object_size_exclude_labels_description = """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``.""" @@ -1685,6 +1833,16 @@ class base_has_image_mean_object_size_model(AssertionModel): description=has_image_mean_object_size_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_object_size_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_object_size_frame_description, + ) + labels: typing.Optional[typing.List[int]] = Field( None, description=has_image_mean_object_size_labels_description, @@ -1740,6 +1898,12 @@ class has_image_mean_object_size_model_nested(AssertionModel): has_image_n_labels_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_n_labels_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_n_labels_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" + has_image_n_labels_labels_description = """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``.""" has_image_n_labels_exclude_labels_description = """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``.""" @@ -1763,6 +1927,16 @@ class base_has_image_n_labels_model(AssertionModel): description=has_image_n_labels_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_n_labels_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_n_labels_frame_description, + ) + labels: typing.Optional[typing.List[int]] = Field( None, description=has_image_n_labels_labels_description, @@ -1897,6 +2071,8 @@ class has_image_width_model_nested(AssertionModel): has_size_model, has_image_center_of_mass_model, has_image_channels_model, + has_image_depth_model, + has_image_frames_model, has_image_height_model, has_image_mean_intensity_model, has_image_mean_object_size_model, @@ -1931,6 +2107,8 @@ class has_image_width_model_nested(AssertionModel): has_size_model_nested, has_image_center_of_mass_model_nested, has_image_channels_model_nested, + has_image_depth_model_nested, + has_image_frames_model_nested, has_image_height_model_nested, has_image_mean_intensity_model_nested, has_image_mean_object_size_model_nested, @@ -1991,6 +2169,10 @@ class assertion_dict(AssertionModel): has_image_channels: typing.Optional[base_has_image_channels_model] = None + has_image_depth: typing.Optional[base_has_image_depth_model] = None + + has_image_frames: typing.Optional[base_has_image_frames_model] = None + has_image_height: typing.Optional[base_has_image_height_model] = None has_image_mean_intensity: typing.Optional[base_has_image_mean_intensity_model] = None diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index c56bd144bc61..d9972994d3fe 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -69,6 +69,24 @@ validators=["check_non_negative_if_set"], ), ] +Depth = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Expected depth of the image (number of slices).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +Frames = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Expected number of frames in the image sequence (number of time steps).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] WidthDelta = Annotated[ XmlInt, AssertionParameter( @@ -150,6 +168,60 @@ validators=["check_non_negative_if_set"], ), ] +DepthDelta = Annotated[ + XmlInt, + AssertionParameter( + "Maximum allowed difference of the image depth (number of slices, default is 0). The observed depth has to be in the range ``value +- delta``.", + json_type="StrictInt", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +DepthMin = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Minimum allowed depth of the image (number of slices).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +DepthMax = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Maximum allowed depth of the image (number of slices).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +FramesDelta = Annotated[ + XmlInt, + AssertionParameter( + "Maximum allowed difference of the number of frames in the image sequence (number of time steps, default is 0). The observed number of frames has to be in the range ``value +- delta``.", + json_type="StrictInt", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +FramesMin = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Minimum allowed number of frames in the image sequence (number of time steps).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +FramesMax = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Maximum allowed number of frames in the image sequence (number of time steps).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] MeanIntensity = Annotated[ OptionalXmlFloat, AssertionParameter("The required mean value of the image intensities.", json_type=JSON_OPTIONAL_STRICT_NUMBER), @@ -218,6 +290,20 @@ json_type="typing.Optional[StrictInt]", ), ] +Slice = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).", + json_type="typing.Optional[StrictInt]", + ), +] +Frame = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).", + json_type="typing.Optional[StrictInt]", + ), +] CenterOfMass = Annotated[ str, AssertionParameter( @@ -319,14 +405,15 @@ def assert_has_image_width( max: WidthMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific width (in pixels). + """ + Asserts the output is an image and has a specific width (in pixels). The width is plus/minus ``delta`` (e.g., ````). Alternatively the range of the expected width can be specified by ``min`` and/or ``max``. """ im_arr = _get_image(output_bytes) _assert_number( - im_arr.shape[1], + im_arr.shape[3], # Image axes are normalized like "TZYXC" width, delta, min, @@ -345,14 +432,15 @@ def assert_has_image_height( max: HeightMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific height (in pixels). + """ + Asserts the output is an image and has a specific height (in pixels). The height is plus/minus ``delta`` (e.g., ````). Alternatively the range of the expected height can be specified by ``min`` and/or ``max``. """ im_arr = _get_image(output_bytes) _assert_number( - im_arr.shape[0], + im_arr.shape[2], # Image axes are normalized like "TZYXC" height, delta, min, @@ -371,16 +459,16 @@ def assert_has_image_channels( max: ChannelsMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific number of channels. + """ + Asserts the output is an image and has a specific number of channels. The number of channels is plus/minus ``delta`` (e.g., ````). Alternatively the range of the expected number of channels can be specified by ``min`` and/or ``max``. """ im_arr = _get_image(output_bytes) - n_channels = 1 if im_arr.ndim < 3 else im_arr.shape[2] # we assume here that the image is a 2-D image _assert_number( - n_channels, + im_arr.shape[-1], # Image axes are normalized like "TZYXC" channels, delta, min, @@ -391,31 +479,168 @@ def assert_has_image_channels( ) +def assert_has_image_depth( + output_bytes: OutputBytes, + depth: Depth = None, + delta: DepthDelta = 0, + min: DepthMin = None, + max: DepthMax = None, + negate: Negate = NEGATE_DEFAULT, +) -> None: + """ + Asserts the output is an image and has a specific depth (number of slices). + + The depth is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``. + """ + im_arr = _get_image(output_bytes) + _assert_number( + im_arr.shape[1], # Image axes are normalized like "TZYXC" + depth, + delta, + min, + max, + negate, + "{expected} depth {n}+-{delta}", + "{expected} depth to be in [{min}:{max}]", + ) + + +def assert_has_image_frames( + output_bytes: OutputBytes, + frames: Frames = None, + delta: FramesDelta = 0, + min: FramesMin = None, + max: FramesMax = None, + negate: Negate = NEGATE_DEFAULT, +) -> None: + """ + Asserts the output is an image and has a specific number of frames (number of time steps). + + The number of frames is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``. + """ + im_arr = _get_image(output_bytes) + _assert_number( + im_arr.shape[0], # Image axes are normalized like "TZYXC" + frames, + delta, + min, + max, + negate, + "{expected} frames {n}+-{delta}", + "{expected} frames 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: + im_arr_yx = im_arr.sum(axis=(0, 1, 4)) # Image axes are normalized like "TZYXC" + im_arr_yx = numpy.abs(im_arr_yx) + if im_arr_yx.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() + im_arr_yx = im_arr_yx / im_arr_yx.sum() + yy, xx = numpy.indices(im_arr_yx.shape) + return (im_arr_yx * xx).sum(), (im_arr_yx * yy).sum() + + +def _move_char(s: str, pos_src: int, pos_dst: int) -> str: + s_list = list(s) + c = s_list.pop(pos_src) + if pos_dst < 0: + pos_dst = len(s_list) + pos_dst + 1 + s_list.insert(pos_dst, c) + return "".join(s_list) + + +def _swap_char(s: str, pos1: int, pos2: int) -> str: + s_list = list(s) + s_list[pos1], s_list[pos2] = s_list[pos2], s_list[pos1] + return "".join(s_list) def _get_image( output_bytes: bytes, channel: Optional[Union[int, str]] = None, + slice: Optional[Union[int, str]] = None, + frame: Optional[Union[int, str]] = None, ) -> "numpy.typing.NDArray": """ - Returns the output image or a specific channel. + Returns the output image with the axes ``TZYXC``, optionally restricted to a specific `channel`, `slice`, + `frame`, or a combination thereof. - The function tries to read the image using tifffile and Pillow. + The function tries to read the image using tifffile and Pillow. The image axes are normalized like ``TZYXC``, + treating sample axis ``S`` as an alias for the channel axis ``C``. For images which cannot be read by tifffile, + two- and three-dimensional data is supported. Two-dimensional images are assumed to be in ``YX`` axes order, + and three-dimensional images are assumed to be in ``YXC`` axes order. """ buf = io.BytesIO(output_bytes) # Try reading with tifffile first. It fails if the file is not a TIFF. try: - im_arr = tifffile.imread(buf) + with tifffile.TiffFile(buf) as im_file: + assert len(im_file.series) == 1, f"Image has unsupported number of series: {len(im_file.series)}" + im_axes = im_file.series[0].axes + + # Verify that the image format is supported + assert ( + frozenset("YX") <= frozenset(im_axes) <= frozenset("TZYXCS") + ), f"Image has unsupported axes: {im_axes}" + + # Treat sample axis "S" as channel axis "C" and fail if both are present + assert ( + "C" not in im_axes or "S" not in im_axes + ), f"Image has sample and channel axes which is not supported: {im_axes}" + im_axes = im_axes.replace("S", "C") + + # Read the image data + im_arr = im_file.asarray() + + # Step 1. In the three steps below, the optional axes are added, of if they arent't there yet: + + # (1.1) Append "C" axis if not present yet + if im_axes.find("C") == -1: + im_arr = im_arr[..., None] + im_axes += "C" + + # (1.2) Append "Z" axis if not present yet + if im_axes.find("Z") == -1: + im_arr = im_arr[..., None] + im_axes += "Z" + + # (1.3) Append "T" axis if not present yet + if im_axes.find("T") == -1: + im_arr = im_arr[..., None] + im_axes += "T" + + # Step 2. All supported axes are there now. Normalize the order of the axes: + + # (2.1) Normalize order of axes "Y" and "X" + ypos = im_axes.find("Y") + xpos = im_axes.find("X") + if ypos > xpos: + im_arr = im_arr.swapaxes(ypos, xpos) + im_axes = _swap_char(im_axes, xpos, ypos) + + # (2.2) Normalize the position of the "C" axis (should be last) + cpos = im_axes.find("C") + if cpos < len(im_axes) - 1: + im_arr = numpy.moveaxis(im_arr, cpos, -1) + im_axes = _move_char(im_axes, cpos, -1) + + # (2.3) Normalize the position of the "T" axis (should be first) + tpos = im_axes.find("T") + if tpos != 0: + im_arr = numpy.moveaxis(im_arr, tpos, 0) + im_axes = _move_char(im_axes, tpos, 0) + + # (2.4) Normalize the position of the "Z" axis (should be second) + zpos = im_axes.find("Z") + if zpos != 1: + im_arr = numpy.moveaxis(im_arr, zpos, 1) + im_axes = _move_char(im_axes, zpos, 1) + + # Verify that the normalizations were successful + assert im_axes == "TZYXC", f"Image axis normalization failed: {im_axes}" # If tifffile failed, then the file is not a tifffile. In that case, try with Pillow. except tifffile.TiffFileError: @@ -423,9 +648,28 @@ def _get_image( with Image.open(buf) as im: im_arr = numpy.array(im) + # Verify that the image format is supported + assert im_arr.ndim in (2, 3), f"Image has unsupported dimension: {im_arr.ndim}" + + # Normalize the axes + if im_arr.ndim == 2: # Append "C" axis if not present yet + im_arr = im_arr[..., None] + im_arr = im_arr[None, None, ...] # Prepend "T" and "Z" axes + + # Verify that the normalizations were successful + assert im_arr.ndim == 5, "Image axis normalization failed" + # Select the specified channel (if any). if channel is not None: - im_arr = im_arr[:, :, int(channel)] + im_arr = im_arr[..., [int(channel)]] + + # Select the specified slice (if any). + if slice is not None: + im_arr = im_arr[:, [int(slice)], ...] + + # Select the specified frame (if any). + if frame is not None: + im_arr = im_arr[[int(frame)], ...] # Return the image return im_arr @@ -434,17 +678,20 @@ def _get_image( def assert_has_image_mean_intensity( output_bytes: OutputBytes, channel: Channel = None, + slice: Slice = None, + frame: Frame = None, mean_intensity: MeanIntensity = None, eps: MeanIntensityEps = 0.01, min: MeanIntensityMin = None, max: MeanIntensityMax = None, ) -> None: - """Asserts the output is an image and has a specific mean intensity value. + """ + Asserts the output is an image and has a specific mean intensity value. The mean intensity value is plus/minus ``eps`` (e.g., ````). Alternatively the range of the expected mean intensity value can be specified by ``min`` and/or ``max``. """ - im_arr = _get_image(output_bytes, channel) + im_arr = _get_image(output_bytes, channel, slice, frame) _assert_float( actual=im_arr.mean(), label="mean intensity", @@ -459,15 +706,18 @@ def assert_has_image_center_of_mass( output_bytes: OutputBytes, center_of_mass: CenterOfMass, channel: Channel = None, + slice: Slice = None, + frame: Frame = None, eps: CenterOfMassEps = 0.01, ) -> None: - """Asserts the specified output is an image and has the specified center of mass. + """ + Asserts the specified output is an image and has the specified center of mass. Asserts the output is an image and has a specific center of mass, or has an Euclidean distance of ``eps`` or less to that point (e.g., ````). """ - im_arr = _get_image(output_bytes, channel) + im_arr = _get_image(output_bytes, channel, slice, frame) center_of_mass_parts = [c.strip() for c in center_of_mass.split(",")] assert len(center_of_mass_parts) == 2 center_of_mass_tuple = (float(center_of_mass_parts[0]), float(center_of_mass_parts[1])) @@ -482,6 +732,8 @@ def assert_has_image_center_of_mass( def _get_image_labels( output_bytes: bytes, channel: Optional[Union[int, str]] = None, + slice: Optional[Union[int, str]] = None, + frame: 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]]: @@ -489,7 +741,7 @@ def _get_image_labels( 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) + im_arr = _get_image(output_bytes, channel, slice, frame) def cast_label(label): label = label.strip() @@ -524,6 +776,8 @@ def cast_label(label): def assert_has_image_n_labels( output_bytes: OutputBytes, channel: Channel = None, + slice: Slice = None, + frame: Frame = None, labels: Labels = None, exclude_labels: ExcludeLabels = None, n: NumLabels = None, @@ -532,14 +786,15 @@ def assert_has_image_n_labels( max: NumLabelsMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has the specified labels. + """ + Asserts the output is an image and has the specified labels. Labels can be a number of labels or unique values (e.g., ````). The primary usage of this assertion is to verify the number of objects in images with uniquely labeled objects. """ - present_labels = _get_image_labels(output_bytes, channel, labels, exclude_labels)[1] + present_labels = _get_image_labels(output_bytes, channel, slice, frame, labels, exclude_labels)[1] _assert_number( len(present_labels), n, @@ -555,6 +810,8 @@ def assert_has_image_n_labels( def assert_has_image_mean_object_size( output_bytes: OutputBytes, channel: Channel = None, + slice: Slice = None, + frame: Frame = None, labels: Labels = None, exclude_labels: ExcludeLabels = None, mean_object_size: MeanObjectSize = None, @@ -562,14 +819,30 @@ def assert_has_image_mean_object_size( min: MeanObjectSizeMin = None, max: MeanObjectSizeMax = None, ) -> None: - """Asserts the output is an image with labeled objects which have the specified mean size (number of pixels), + """ + Asserts the output is an image with labeled objects which have the specified mean size (number of pixels), The mean size is plus/minus ``eps`` (e.g., ````). The labels must be unique. """ - 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) + im_arr, present_labels = _get_image_labels(output_bytes, channel, slice, frame, labels, exclude_labels) + assert ( + im_arr.shape[-1] == 1 + ), f"has_image_mean_object_size is undefined for multi-channel images (channels: {im_arr.shape[-1]})" + + # Build list of all object sizes over all time-frames + object_sizes = sum( + [ + # Iterate over all XYZC time-frames (axis C is singleton) + [(im_arr_t == label).sum() for label in present_labels] + for im_arr_t in im_arr + ], + [], + ) + + # Compute the mean object size and verify + actual_mean_object_size = numpy.mean([object_size for object_size in object_sizes if object_size > 0]) _assert_float( actual=actual_mean_object_size, label="mean object size", diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 3f077b79abdb..c8090c919568 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -3051,6 +3051,16 @@ $attribute_list::5]]> + + + + + + + + + + @@ -3097,6 +3107,82 @@ $attribute_list::5]]> + + + + ``). +Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``. + +$attribute_list::5]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ``). +Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``. + +$attribute_list::5]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3151,6 +3237,16 @@ $attribute_list::5]]> + + + + + + + + + + @@ -3190,6 +3286,16 @@ $attribute_list::5]]> + + + + + + + + + + @@ -3240,6 +3346,16 @@ $attribute_list::5]]> + + + + + + + + + + diff --git a/test-data/im5_uint8.tif b/test-data/im5_uint8.tif new file mode 100644 index 000000000000..5dca8cad973d Binary files /dev/null and b/test-data/im5_uint8.tif differ diff --git a/test-data/im6_uint8.tif b/test-data/im6_uint8.tif new file mode 100644 index 000000000000..5221d413ea3f Binary files /dev/null and b/test-data/im6_uint8.tif differ diff --git a/test-data/im7_uint8.tif b/test-data/im7_uint8.tif new file mode 100644 index 000000000000..c6f3d7ba20f9 Binary files /dev/null and b/test-data/im7_uint8.tif differ diff --git a/test-data/im8_uint16.tif b/test-data/im8_uint16.tif new file mode 100644 index 000000000000..be4c15380cea Binary files /dev/null and b/test-data/im8_uint16.tif differ diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index a3c93ea84f67..c94c3581c3f1 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -11,7 +11,7 @@ - + @@ -22,7 +22,7 @@ - + @@ -30,7 +30,7 @@ - + @@ -38,7 +38,7 @@ - + @@ -54,7 +54,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -70,7 +70,7 @@ - + @@ -78,7 +78,7 @@ - + @@ -86,16 +86,78 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - +