Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add content assertion XML tags for test output verification using images #17581

Merged
merged 50 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
9a15904
Add `has_image_metadata` test assertion
kostrykin Feb 28, 2024
5e4cab2
Fix test
kostrykin Feb 28, 2024
aad820f
Fix syntax error
kostrykin Feb 28, 2024
0b04d84
Add missing import
kostrykin Feb 28, 2024
f85d6bf
Fix test
kostrykin Feb 28, 2024
c9ba5d2
Fix bug
kostrykin Feb 28, 2024
900881b
Fix bug
kostrykin Feb 28, 2024
982e128
Add more metadata assertions
kostrykin Feb 28, 2024
7af2793
Add tests for label images
kostrykin Feb 28, 2024
d182c5b
Fix XML
kostrykin Feb 28, 2024
fa81d00
Fix XML
kostrykin Feb 28, 2024
ca9e936
Fix bug
kostrykin Feb 28, 2024
e4b1ecb
Fix bug
kostrykin Feb 28, 2024
9e361d4
Fix bug
kostrykin Feb 28, 2024
a1ddff0
Fix tests
kostrykin Feb 28, 2024
c4ea727
Add `image_has_intensities` and tests
kostrykin Feb 28, 2024
e8bb59e
Fix bug
kostrykin Feb 28, 2024
a392d9e
Fix tests
kostrykin Feb 28, 2024
6cfe399
Fix tests
kostrykin Feb 28, 2024
c61e306
Add debug info
kostrykin Feb 28, 2024
8c56754
Fix bug
kostrykin Feb 28, 2024
04ade00
Fix tests
kostrykin Feb 28, 2024
37c1cba
Add test
kostrykin Feb 28, 2024
3ed81ec
Add XSD for `image_has_metadata`
kostrykin Feb 29, 2024
d8e3e3e
Fix bug in test
kostrykin Feb 29, 2024
88d67e3
Update XSD
kostrykin Feb 29, 2024
8036ae8
Update XSD
kostrykin Feb 29, 2024
6855df5
Fix assertion errors
kostrykin Mar 2, 2024
42e1b07
Fix linting issues
kostrykin Mar 2, 2024
4d52d07
Fix linting issues
kostrykin Mar 2, 2024
9094a7f
Test
kostrykin Mar 2, 2024
af948be
Test
kostrykin Mar 2, 2024
f1f7d48
Fix linting issues
kostrykin Mar 2, 2024
94d43f1
Fix linting issues
kostrykin Mar 2, 2024
7d0ef9f
Add missing type checks
kostrykin Mar 2, 2024
7bcc0ea
Fix linting issues
kostrykin Mar 2, 2024
f08d68f
Fix linting issues
kostrykin Mar 2, 2024
f7845ce
Fix linting issues
kostrykin Mar 2, 2024
84c4cbf
Fix linting issues
kostrykin Mar 2, 2024
ef9302c
Fix linting issues
kostrykin Mar 2, 2024
10b05d5
Fix linting issues
kostrykin Mar 2, 2024
5ce6045
Add `*_min` and `*_max` for `mean_intensity` and `mean_object_size`, …
kostrykin Mar 2, 2024
7aebd26
Add `_assert_float` function and refactor
kostrykin Mar 2, 2024
00485cc
Squashed commit of the following:
kostrykin Mar 5, 2024
fb7bbaa
Rename `delta` to `eps` for float-based assertions (#1)
kostrykin Mar 11, 2024
5964f72
Rename assertion attribute `value`
kostrykin Mar 13, 2024
8a81593
Rename `has_image_labels` to `has_image_n_labels`
kostrykin Mar 13, 2024
762e22c
Update lib/galaxy/tool_util/verify/asserts/image.py
kostrykin Mar 13, 2024
0210711
Update lib/galaxy/tool_util/verify/asserts/image.py
kostrykin Mar 13, 2024
209d15f
Fix linting issues
kostrykin Mar 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/galaxy/tool_util/verify/asserts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
289 changes: 289 additions & 0 deletions lib/galaxy/tool_util/verify/asserts/image.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading