diff --git a/package/PartSeg/plugins/napari_widgets/simple_measurement_widget.py b/package/PartSeg/plugins/napari_widgets/simple_measurement_widget.py index 6a84f25b5..e98227190 100644 --- a/package/PartSeg/plugins/napari_widgets/simple_measurement_widget.py +++ b/package/PartSeg/plugins/napari_widgets/simple_measurement_widget.py @@ -84,7 +84,7 @@ def _calculate(self, event=None): ) return data_scale = data_layer.scale[-3:] / UNIT_SCALE[self.scale_units_select.get_value().value] - image = Image(data_layer.data, data_scale, axes_order="TZYX"[-data_ndim:]) + image = Image(data_layer.data, spacing=data_scale, axes_order="TZYX"[-data_ndim:]) worker = _prepare_data(profile, image, self.labels_choice.value.data) worker.returned.connect(self._calculate_next) worker.errored.connect(self._finished) diff --git a/package/PartSeg/plugins/napari_widgets/utils.py b/package/PartSeg/plugins/napari_widgets/utils.py index e621a1bc9..322cb06f3 100644 --- a/package/PartSeg/plugins/napari_widgets/utils.py +++ b/package/PartSeg/plugins/napari_widgets/utils.py @@ -12,7 +12,7 @@ from PartSeg.common_gui.custom_save_dialog import FormDialog from PartSegCore import UNIT_SCALE, Units from PartSegCore.algorithm_describe_base import AlgorithmProperty -from PartSegImage import Channel, Image +from PartSegImage import Channel, ChannelInfo, Image class QtNapariAlgorithmProperty(QtAlgorithmProperty): @@ -69,9 +69,9 @@ def generate_image(viewer: Viewer, *layer_names): image_list.append( Image( image_layer.data, - data_scale, + spacing=data_scale, axes_order=axis_order[-image_layer.data.ndim :], - channel_names=[image_layer.name], + channel_info=[ChannelInfo(name=name.value)], ) ) res_image = image_list[0] diff --git a/package/PartSegCore/analysis/measurement_calculation.py b/package/PartSegCore/analysis/measurement_calculation.py index e4eec3d6d..6f148aae9 100644 --- a/package/PartSegCore/analysis/measurement_calculation.py +++ b/package/PartSegCore/analysis/measurement_calculation.py @@ -208,7 +208,7 @@ def get_global_parameters(self): res = [name] iterator = iter(self._data_dict.keys()) with suppress(StopIteration): - next(iterator) # skipcq: PTC-W0063` + next(iterator) # skipcq: PTC-W0063 else: res = [] iterator = iter(self._data_dict.keys()) @@ -233,7 +233,7 @@ def _prepare_res_iterator(self, counts): res = [[name] for _ in range(counts)] iterator = iter(self._data_dict.keys()) with suppress(StopIteration): - next(iterator) # skipcq: PTC-W0063` + next(iterator) # skipcq: PTC-W0063 else: res = [[] for _ in range(counts)] iterator = iter(self._data_dict.keys()) diff --git a/package/PartSegCore/image_transforming/image_projection.py b/package/PartSegCore/image_transforming/image_projection.py index 0417e083f..69aa95fab 100644 --- a/package/PartSegCore/image_transforming/image_projection.py +++ b/package/PartSegCore/image_transforming/image_projection.py @@ -58,8 +58,8 @@ def transform( return ( image.__class__( data=new_channels, - image_spacing=tuple(spacing), - channel_names=image.channel_names, + spacing=tuple(spacing), + channel_info=image.channel_info, mask=new_mask, axes_order=image.axis_order, ), diff --git a/package/PartSegCore/mask/io_functions.py b/package/PartSegCore/mask/io_functions.py index 2ec87fb53..8c268afa0 100644 --- a/package/PartSegCore/mask/io_functions.py +++ b/package/PartSegCore/mask/io_functions.py @@ -132,7 +132,7 @@ def _save_mask_roi(project: MaskProjectTuple, tar_file: tarfile.TarFile, paramet spacing = project.image.spacing else: spacing = parameters.spacing - segmentation_image = Image(project.roi_info.roi, spacing, axes_order=Image.axis_order.replace("C", "")) + segmentation_image = Image(project.roi_info.roi, spacing=spacing, axes_order=Image.axis_order.replace("C", "")) try: ImageWriter.save(segmentation_image, segmentation_buff, compression=None) except ValueError: diff --git a/package/PartSegCore/napari_plugins/save_tiff_layer.py b/package/PartSegCore/napari_plugins/save_tiff_layer.py index 2e8444118..a77c956a6 100644 --- a/package/PartSegCore/napari_plugins/save_tiff_layer.py +++ b/package/PartSegCore/napari_plugins/save_tiff_layer.py @@ -4,7 +4,7 @@ import numpy as np from napari.types import FullLayerData -from PartSegImage import Image, ImageWriter +from PartSegImage import ChannelInfo, Image, ImageWriter from PartSegImage.image import DEFAULT_SCALE_FACTOR @@ -15,9 +15,9 @@ def napari_write_labels(path: str, data: Any, meta: dict) -> Optional[str]: scale_shift = min(data.ndim, 3) image = Image( data, - np.divide(meta["scale"], DEFAULT_SCALE_FACTOR)[-scale_shift:], + spacing=np.divide(meta["scale"], DEFAULT_SCALE_FACTOR)[-scale_shift:], axes_order="TZYX"[-data.ndim :], - channel_names=[meta["name"]], + channel_info=[ChannelInfo(name=meta["name"])], shift=np.divide(meta["translate"], DEFAULT_SCALE_FACTOR)[-scale_shift:], name="ROI", ) @@ -50,9 +50,9 @@ def napari_write_images(path: str, layer_data: List[FullLayerData]) -> List[str] scale_shift -= 1 image = Image( data, - np.divide(meta["scale"], DEFAULT_SCALE_FACTOR)[-scale_shift:], + spacing=np.divide(meta["scale"], DEFAULT_SCALE_FACTOR)[-scale_shift:], axes_order=axes, - channel_names=channel_names, + channel_info=[ChannelInfo(name=x) for x in channel_names], shift=np.divide(meta["translate"], DEFAULT_SCALE_FACTOR)[-scale_shift:], name="Image", ) diff --git a/package/PartSegImage/__init__.py b/package/PartSegImage/__init__.py index 865edfa7f..4ef810340 100644 --- a/package/PartSegImage/__init__.py +++ b/package/PartSegImage/__init__.py @@ -3,7 +3,7 @@ from PartSegImage import tifffile_fixes # noqa: F401 from PartSegImage.channel_class import Channel -from PartSegImage.image import Image +from PartSegImage.image import ChannelInfo, ChannelInfoFull, Image from PartSegImage.image_reader import ( CziImageReader, GenericImageReader, @@ -17,6 +17,8 @@ __all__ = ( "BaseImageWriter", "Channel", + "ChannelInfo", + "ChannelInfoFull", "Image", "TiffImageReader", "IMAGEJImageWriter", diff --git a/package/PartSegImage/image.py b/package/PartSegImage/image.py index a20046955..5bbd94926 100644 --- a/package/PartSegImage/image.py +++ b/package/PartSegImage/image.py @@ -1,14 +1,18 @@ from __future__ import annotations import re +import sys import typing import warnings -from collections.abc import Iterable from contextlib import suppress +from copy import copy +from dataclasses import dataclass +from functools import wraps +from itertools import cycle, zip_longest import numpy as np -from PartSegImage import Channel +from PartSegImage.channel_class import Channel Spacing = typing.Tuple[typing.Union[float, int], ...] _IMAGE_DATA = typing.Union[typing.List[np.ndarray], np.ndarray] @@ -18,6 +22,31 @@ DEFAULT_SCALE_FACTOR = 10**9 +ch_par: dict[str, bool] + +if sys.version_info[:2] > (3, 9): + ch_par = {"kw_only": True, "slots": True} +else: + ch_par = {} + + +@dataclass(**ch_par) +class ChannelInfo: + name: str + color_map: str | np.ndarray | tuple | list | None = None + contrast_limits: tuple[float, float] | None = None + + +@dataclass(**ch_par) +class ChannelInfoFull: + name: str + color_map: str | np.ndarray + contrast_limits: tuple[float, float] + + def __post_init__(self): + if not isinstance(self.color_map, (str, np.ndarray)): + self.color_map = np.array(self.color_map) + def minimal_dtype(val: int): """ @@ -65,17 +94,98 @@ def reduce_array( return translate[array] +def rename_argument(from_name: str, to_name: str, since_version: str): + def decorator(fun): + @wraps(fun) + def _fun(*args, **kwargs): + if from_name in kwargs: + warnings.warn( + f"Argument {from_name} is deprecated since {since_version}. Use {to_name} instead", + DeprecationWarning, + stacklevel=2, + ) + kwargs[to_name] = kwargs.pop(from_name) + return fun(*args, **kwargs) + + return _fun + + return decorator + + +def positional_to_named(fun): + @wraps(fun) + def _fun(*args, **kwargs): + if len(args) > 2: + warnings.warn( + "Since PartSeg 0.15.4 all arguments, except first one, should be named", + DeprecationWarning, + stacklevel=2, + ) + + for name, arg in zip( + ( + "spacing", + "file_path", + "mask", + "default_coloring", + "ranges", + "axes_order", + "shift", + "name", + "metadata_dict", + ), + args[2:], + # start from 2 because first two arguments are self and data + ): + kwargs[name] = arg + + return fun(*args[:2], **kwargs) + + return _fun + + +def merge_into_channel_info(fun): + @wraps(fun) + def _fun(*args, **kwargs): + if "channel_info" in kwargs: + fun(*args, **kwargs) + return None + channel_names = kwargs.pop("channel_names", []) + default_coloring = kwargs.pop("default_coloring", []) + ranges = kwargs.pop("ranges", []) + if any([channel_names, default_coloring, ranges]): + if isinstance(channel_names, str): + channel_names = [channel_names] + if channel_names is None: + channel_names = [] + if default_coloring is None: + default_coloring = [] + if ranges is None: + ranges = [] + warnings.warn( + "Using channel_names, default_coloring and ranges is deprecated since PartSeg 0.15.4", + DeprecationWarning, + stacklevel=2, + ) + channel_info = [ + ChannelInfo(name=name, color_map=color, contrast_limits=contrast_limits) + for name, color, contrast_limits in zip_longest(channel_names, default_coloring, ranges) + ] + kwargs["channel_info"] = channel_info + return fun(*args, **kwargs) + + return _fun + + class Image: """ Base class for Images used in PartSeg :param data: 5-dim array with order: time, z, y, x, channel - :param image_spacing: spacing for z, y, x + :param spacing: spacing for z, y, x :param file_path: path to image on disc :param mask: mask array in shape z,y,x - :param default_coloring: default colormap - not used yet - :param ranges: default ranges for channels - :param channel_names: labels for channels + :param channel_info: list of metadata stored per channel :param axes_order: allow to create Image object form data with different axes order, or missed axes :cvar str ~.axis_order: internal order of axes @@ -98,19 +208,21 @@ def __new__(cls, *args, **kwargs): cls.array_axis_order = cls.axis_order.replace("C", "") return super().__new__(cls) + @positional_to_named + @rename_argument("image_spacing", "spacing", "0.15.4") + @merge_into_channel_info def __init__( self, data: _IMAGE_DATA, - image_spacing: Spacing, + *, + spacing: Spacing, file_path=None, mask: None | np.ndarray = None, - default_coloring=None, - ranges=None, - channel_names=None, + channel_info: list[ChannelInfo | ChannelInfoFull] | None = None, axes_order: str | None = None, shift: Spacing | None = None, name: str = "", - metadata: dict | None = None, + metadata_dict: dict | None = None, ): # TODO add time distance to image spacing if axes_order is None: # pragma: no cover @@ -121,25 +233,62 @@ def __init__( ) axes_order = self.axis_order self._check_data_dimensionality(data, axes_order) - if not isinstance(image_spacing, tuple): - image_spacing = tuple(image_spacing) + if not isinstance(spacing, tuple): + spacing = tuple(spacing) self._channel_arrays = self._split_data_on_channels(data, axes_order) - self._image_spacing = (1.0,) * (3 - len(image_spacing)) + image_spacing + self._image_spacing = (1.0,) * (3 - len(spacing)) + spacing self._image_spacing = tuple(el if el > 0 else 10**-6 for el in self._image_spacing) self._shift = tuple(shift) if shift is not None else (0,) * len(self._image_spacing) self.name = name self.file_path = file_path - self.default_coloring = default_coloring - if self.default_coloring is not None: - self.default_coloring = [np.array(x) for x in default_coloring] - self._channel_names = self._prepare_channel_names(channel_names, self.channels) - - self.ranges = self._adjust_ranges(ranges, self._channel_arrays) self._mask_array = self._fit_mask(mask, data, axes_order) - self.metadata = dict(metadata) if metadata is not None else {} + self._channel_info = self._adjust_channel_info(channel_info, self._channel_arrays) + self.metadata = dict(metadata_dict) if metadata_dict is not None else {} + + @staticmethod + def _adjust_channel_info( + channel_info: list[ChannelInfo | ChannelInfoFull] | None, channel_array: list[np.ndarray] + ) -> list[ChannelInfoFull]: + default_colors = cycle(["red", "blue", "green", "yellow", "magenta", "cyan"]) + if channel_info is None: + ranges = [(np.min(x), np.max(x)) for x in channel_array] + return [ + ChannelInfoFull(name=f"channel {i}", color_map=x[0], contrast_limits=x[1]) + for i, x in enumerate(zip(default_colors, ranges), start=1) + ] + + channel_info = channel_info[: len(channel_array)] + + res = [] + + for i, ch_inf in enumerate(channel_info): + res.append( + ChannelInfoFull( + name=ch_inf.name or f"channel {i+1}", + color_map=( + ch_inf.color_map if ch_inf.color_map is not None else next(default_colors) # skipcq: PTC-W0063 + ), + contrast_limits=( + ch_inf.contrast_limits + if ch_inf.contrast_limits is not None + else (np.min(channel_array[i]), np.max(channel_array[i])) + ), + ) + ) + + for i, arr in enumerate(channel_array[len(res) :], start=len(channel_info)): + res.append( + ChannelInfoFull( + name=f"channel {i+1}", + color_map=next(default_colors), # skipcq: PTC-W0063 + contrast_limits=(np.min(arr), np.max(arr)), + ) + ) + + return res @staticmethod def _check_data_dimensionality(data, axes_order): @@ -155,14 +304,6 @@ def _check_data_dimensionality(data, axes_order): f"like length of axes_order (axis :{len(axes_order)}, ndim: {ndim}" ) - @staticmethod - def _adjust_ranges( - ranges: list[tuple[float, float]] | None, channel_arrays: list[np.ndarray] - ) -> list[tuple[float, float]]: - if ranges is None: - ranges = list(zip((np.min(c) for c in channel_arrays), (np.max(c) for c in channel_arrays))) - return [(min_val, max_val) if (min_val != max_val) else (min_val, min_val + 1) for (min_val, max_val) in ranges] - def _fit_mask(self, mask, data, axes_order): mask_array = self._prepare_mask(mask, data, axes_order) if mask_array is not None: @@ -184,18 +325,6 @@ def _prepare_mask(cls, mask, data, axes_order) -> np.ndarray | None: mask = cls._fit_array_to_image(data_shape, mask) return cls.reorder_axes(mask, axes_order.replace("C", "")) - @staticmethod - def _prepare_channel_names(channel_names, channels_num) -> list[str]: - default_channel_names = [f"channel {i + 1}" for i in range(channels_num)] - if isinstance(channel_names, str): - channel_names = [channel_names] - if isinstance(channel_names, Iterable): - channel_names_list = [str(x) for x in channel_names] - channel_names_list = channel_names_list[:channels_num] + default_channel_names[len(channel_names_list) :] - else: - channel_names_list = default_channel_names - return channel_names_list[:channels_num] - @classmethod def _split_data_on_channels(cls, data: np.ndarray | list[np.ndarray], axes_order: str) -> list[np.ndarray]: if isinstance(data, list) and not axes_order.startswith("C"): # pragma: no cover @@ -239,6 +368,18 @@ def _merge_channel_names(base_channel_names: list[str], new_channel_names: list[ base_channel_names.append(new_name) return base_channel_names + @property + def channel_info(self) -> list[ChannelInfoFull]: + return [copy(x) for x in self._channel_info] + + @property + def ranges(self) -> list[tuple[float, float]]: + return [x.contrast_limits for x in self._channel_info] + + @property + def default_coloring(self) -> list[str | np.ndarray]: + return [x.color_map for x in self._channel_info] + def merge(self, image: Image, axis: str) -> Image: """ Produce new image merging image data along given axis. All metadata @@ -268,7 +409,7 @@ def merge(self, image: Image, axis: str) -> Image: @property def channel_names(self) -> list[str]: - return self._channel_names[:] + return [x.name for x in self._channel_info] @property def channel_pos(self) -> int: # pragma: no cover @@ -353,16 +494,20 @@ def substitute( default_coloring = self.default_coloring if default_coloring is None else default_coloring ranges = self.ranges if ranges is None else ranges channel_names = self.channel_names if channel_names is None else channel_names + + channel_info = [ + ChannelInfoFull(name=name, color_map=color, contrast_limits=contrast_limits) + for name, color, contrast_limits in zip_longest(channel_names, default_coloring, ranges) + ] + return self.__class__( data=data, - image_spacing=image_spacing, + spacing=image_spacing, file_path=file_path, mask=mask, - default_coloring=default_coloring, - ranges=ranges, - channel_names=channel_names, axes_order=self.axis_order, - metadata=self.metadata, + channel_info=channel_info, + metadata_dict=self.metadata, ) def set_mask(self, mask: np.ndarray | None, axes: str | None = None): @@ -739,28 +884,25 @@ def cut_image( return self.__class__( data=self._image_data_normalize(new_image), - image_spacing=self._image_spacing, + spacing=self._image_spacing, file_path=None, mask=new_mask, - default_coloring=self.default_coloring, - ranges=self.ranges, - channel_names=self.channel_names, + channel_info=self._channel_info, axes_order=self.axis_order, ) def get_imagej_colors(self): - # TODO review - if self.default_coloring is None: - return None - try: - if len(self.default_coloring) != self.channels: - return None - except TypeError: - return None res = [] for color in self.default_coloring: - if color.ndim == 1: - res.append(np.array([np.linspace(0, x, num=256) for x in color])) + + if isinstance(color, str): + if color.startswith("#"): + color_array = _hex_to_rgb(color) + else: + color_array = _name_to_rgb(color) + res.append(np.array([np.linspace(0, x, num=256) for x in color_array]).astype(np.uint8)) + elif color.ndim == 1: + res.append(np.array([np.linspace(0, x, num=256) for x in color]).astype(np.uint8)) else: if color.shape[1] != 256: res.append( @@ -775,12 +917,11 @@ def get_imagej_colors(self): return res def get_colors(self): - # TODO review - if self.default_coloring is None: - return None res = [] for color in self.default_coloring: - if color.ndim == 2: + if isinstance(color, str): + res.append(color) + elif color.ndim == 2: res.append(list(color[:, -1])) else: res.append(list(color)) @@ -823,3 +964,45 @@ def _image_data_normalize(cls, data: _IMAGE_DATA) -> _IMAGE_DATA: if "C" not in cls.axis_order: return data[0] return np.stack(data, axis=cls.axis_order.index("C")) + + +def _hex_to_rgb(hex_code: str) -> tuple[int, int, int]: + """ + Convert a hex color code to an RGB tuple. + + :param str hex_code: The hex color code, either short form (#RGB) or long form (#RRGGBB) + :return: A tuple containing the RGB values (R, G, B) + """ + hex_code = hex_code.lstrip("#") + + if len(hex_code) == 3: + hex_code = "".join([c * 2 for c in hex_code]) + elif len(hex_code) != 6: + raise ValueError(f"Invalid hex code format: {hex_code}") + + return int(hex_code[:2], 16), int(hex_code[2:4], 16), int(hex_code[4:6], 16) + + +def _name_to_rgb(name: str) -> tuple[int, int, int]: + """ + Convert a color name to an RGB tuple. + + :param str name: The color name + :return: A tuple containing the RGB values (R, G, B) + """ + if name not in _NAMED_COLORS: + raise ValueError(f"Unknown color name: {name}") + return _hex_to_rgb(_NAMED_COLORS[name]) + + +_NAMED_COLORS = { + "red": "#FF0000", + "green": "#00FF00", + "blue": "#0000FF", + "yellow": "#FFFF00", + "cyan": "#00FFFF", + "magenta": "#FF00FF", + "white": "#FFFFFF", + "black": "#000000", + "orange": "#FFA500", +} diff --git a/package/PartSegImage/image_reader.py b/package/PartSegImage/image_reader.py index 634be9e18..08cde4cce 100644 --- a/package/PartSegImage/image_reader.py +++ b/package/PartSegImage/image_reader.py @@ -5,6 +5,7 @@ from contextlib import suppress from importlib.metadata import version from io import BytesIO +from itertools import zip_longest from pathlib import Path from threading import Lock @@ -16,7 +17,7 @@ from oiffile import OifFile from packaging.version import parse as parse_version -from PartSegImage.image import Image +from PartSegImage.image import ChannelInfo, Image INCOMPATIBLE_IMAGE_MASK = "Incompatible shape of mask and image" @@ -28,6 +29,12 @@ CZI_MAX_WORKERS = None +def li_if_no(value): + if value is None: + return [] + return value + + class ZSTD1Header(typing.NamedTuple): """ ZSTD1 header structure @@ -318,10 +325,10 @@ def read(self, image_path: typing.Union[str, Path], mask_path=None, ext=None) -> # TODO add mask reading return self.image_class( image_data, - self.spacing, + spacing=self.spacing, file_path=os.path.abspath(image_path), axes_order=self.return_order(), - metadata=image_file.mainfile, + metadata_dict=image_file.mainfile, ) def _read_scale_parameter(self, image_file): @@ -372,11 +379,11 @@ def read(self, image_path: typing.Union[str, BytesIO, Path], mask_path=None, ext image_file.close() return self.image_class( image_data, - self.spacing, + spacing=self.spacing, file_path=image_path, axes_order=self.return_order(), - metadata=metadata, - channel_names=self.channel_names, + metadata_dict=metadata, + channel_info=[ChannelInfo(name=name) for name in self.channel_names or []], ) @classmethod @@ -515,18 +522,22 @@ def report_func(): if not isinstance(image_path, (str, Path)): image_path = "" + channel_info = [ + ChannelInfo(name=name, color_map=color, contrast_limits=contrast_limits) + for name, color, contrast_limits in zip_longest( + li_if_no(self.channel_names), li_if_no(self.colors), li_if_no(self.ranges) + ) + ] return self.image_class( image_data, - self.spacing, + spacing=self.spacing, mask=mask_data, - default_coloring=self.colors, - channel_names=self.channel_names, - ranges=self.ranges, + channel_info=channel_info, file_path=os.path.abspath(image_path), axes_order=self.return_order(), shift=self.shift, name=self.name, - metadata=self.metadata, + metadata_dict=self.metadata, ) @staticmethod diff --git a/package/tests/test_PartSeg/test_base_widgets.py b/package/tests/test_PartSeg/test_base_widgets.py index ffa2ca023..67137f6f0 100644 --- a/package/tests/test_PartSeg/test_base_widgets.py +++ b/package/tests/test_PartSeg/test_base_widgets.py @@ -193,7 +193,7 @@ def test_swap_time(self, qtbot, part_settings, image, monkeypatch): set_project_info_mock.assert_not_called() def test_time_and_stack_image(self, qtbot, part_settings, monkeypatch): - image = Image(np.zeros((10, 10, 10, 10)), image_spacing=(1, 1, 1), axes_order="TZXY") + image = Image(np.zeros((10, 10, 10, 10)), spacing=(1, 1, 1), axes_order="TZXY") main_menu = BaseMainMenu(part_settings, None) qtbot.addWidget(main_menu) warnings_mock = MagicMock() diff --git a/package/tests/test_PartSeg/test_common_backend.py b/package/tests/test_PartSeg/test_common_backend.py index 32a8851ef..1b115c386 100644 --- a/package/tests/test_PartSeg/test_common_backend.py +++ b/package/tests/test_PartSeg/test_common_backend.py @@ -257,7 +257,7 @@ def test_empty_image(self, caplog): def test_run(self, qtbot): algorithm = ROIExtractionAlgorithmForTest() thr = segmentation_thread.SegmentationThread(algorithm) - image = Image(np.zeros((10, 10), dtype=np.uint8), image_spacing=(1, 1), axes_order="XY") + image = Image(np.zeros((10, 10), dtype=np.uint8), spacing=(1, 1), axes_order="XY") algorithm.set_image(image) with qtbot.waitSignals([thr.execution_done, thr.progress_signal]): thr.run() @@ -265,7 +265,7 @@ def test_run(self, qtbot): def test_run_return_none(self, qtbot): algorithm = ROIExtractionAlgorithmForTest(return_none=True) thr = segmentation_thread.SegmentationThread(algorithm) - image = Image(np.zeros((10, 10), dtype=np.uint8), image_spacing=(1, 1), axes_order="XY") + image = Image(np.zeros((10, 10), dtype=np.uint8), spacing=(1, 1), axes_order="XY") algorithm.set_image(image) with qtbot.assertNotEmitted(thr.execution_done): thr.run() @@ -273,7 +273,7 @@ def test_run_return_none(self, qtbot): def test_run_exception(self, qtbot): algorithm = ROIExtractionAlgorithmForTest(raise_=True) thr = segmentation_thread.SegmentationThread(algorithm) - image = Image(np.zeros((10, 10), dtype=np.uint8), image_spacing=(1, 1), axes_order="XY") + image = Image(np.zeros((10, 10), dtype=np.uint8), spacing=(1, 1), axes_order="XY") algorithm.set_image(image) with qtbot.assertNotEmitted(thr.execution_done), qtbot.waitSignal(thr.exception_occurred): thr.run() @@ -467,7 +467,7 @@ def question(*args, **kwargs): @pytest.fixture def image(tmp_path): data = np.random.default_rng().uniform(size=(10, 10, 2)) - return Image(data=data, image_spacing=(10, 10), axes_order="XYC", file_path=str(tmp_path / "test.tiff")) + return Image(data=data, spacing=(10, 10), axes_order="XYC", file_path=str(tmp_path / "test.tiff")) @pytest.fixture @@ -710,21 +710,23 @@ def test_base_settings_partial_load_dump(self, tmp_path, qtbot): assert res[0] == {"cc": 11, "dd": 12} def test_base_settings_verify_image(self): - assert base_settings.BaseSettings.verify_image(Image(np.zeros((10, 10)), (10, 10), axes_order="YX")) - assert base_settings.BaseSettings.verify_image(Image(np.zeros((10, 10, 10)), (10, 10, 10), axes_order="ZYX")) + assert base_settings.BaseSettings.verify_image(Image(np.zeros((10, 10)), spacing=(10, 10), axes_order="YX")) + assert base_settings.BaseSettings.verify_image( + Image(np.zeros((10, 10, 10)), spacing=(10, 10, 10), axes_order="ZYX") + ) with pytest.raises(base_settings.SwapTimeStackException): base_settings.BaseSettings.verify_image( - Image(np.zeros((10, 10, 10)), (10, 10, 10), axes_order="TYX"), silent=False + Image(np.zeros((10, 10, 10)), spacing=(10, 10, 10), axes_order="TYX"), silent=False ) new_image = base_settings.BaseSettings.verify_image( - Image(np.zeros((10, 10, 10)), (10, 10, 10), axes_order="TYX"), silent=True + Image(np.zeros((10, 10, 10)), spacing=(10, 10, 10), axes_order="TYX"), silent=True ) assert new_image.is_stack assert not new_image.is_time with pytest.raises(base_settings.TimeAndStackException): base_settings.BaseSettings.verify_image( - Image(np.zeros((2, 10, 10, 10)), (10, 10, 10), axes_order="TZYX"), silent=True + Image(np.zeros((2, 10, 10, 10)), spacing=(10, 10, 10), axes_order="TZYX"), silent=True ) def test_base_settings_path_history(self, tmp_path, qtbot, monkeypatch): diff --git a/package/tests/test_PartSeg/test_common_gui.py b/package/tests/test_PartSeg/test_common_gui.py index b26f40bc6..2a33fa41e 100644 --- a/package/tests/test_PartSeg/test_common_gui.py +++ b/package/tests/test_PartSeg/test_common_gui.py @@ -152,7 +152,7 @@ def __str__(self): def _example_tiff_files(tmp_path): for i in range(5): ImageWriter.save( - Image(np.random.default_rng().uniform(size=(10, 10)), image_spacing=(1, 1), axes_order="XY"), + Image(np.random.default_rng().uniform(size=(10, 10)), spacing=(1, 1), axes_order="XY"), tmp_path / f"img_{i}.tif", ) @@ -175,7 +175,7 @@ def _example_mask_project_files(tmp_path): data = np.zeros((10, 10), dtype=np.uint8) data[:5] = 1 data[5:] = 2 - image = Image(data, image_spacing=(1, 1), axes_order="XY", file_path=str(tmp_path / "mask.tif")) + image = Image(data, spacing=(1, 1), axes_order="XY", file_path=str(tmp_path / "mask.tif")) ImageWriter.save(image, image.file_path) project = MaskProjectTuple(file_path=image.file_path, image=image, roi_info=ROIInfo(image.fit_array_to_image(data))) SaveROI.save(tmp_path / "proj.seg", project, SaveROIOptions()) diff --git a/package/tests/test_PartSeg/test_napari_image_view.py b/package/tests/test_PartSeg/test_napari_image_view.py index f2833a6ec..869032ffd 100644 --- a/package/tests/test_PartSeg/test_napari_image_view.py +++ b/package/tests/test_PartSeg/test_napari_image_view.py @@ -48,7 +48,7 @@ def get_color_dict(layer): def test_image_info(): - image_info = ImageInfo(Image(np.zeros((10, 10)), image_spacing=(1, 1), axes_order="XY"), []) + image_info = ImageInfo(Image(np.zeros((10, 10)), spacing=(1, 1), axes_order="XY"), []) assert not image_info.coords_in([1, 1]) assert np.all(image_info.translated_coords([1, 1]) == [1, 1]) diff --git a/package/tests/test_PartSeg/test_settings.py b/package/tests/test_PartSeg/test_settings.py index b5f0b4dfa..0182afffc 100644 --- a/package/tests/test_PartSeg/test_settings.py +++ b/package/tests/test_PartSeg/test_settings.py @@ -268,7 +268,7 @@ def test_add_point(self, tmp_path, qtbot): def test_set_roi(self, tmp_path, qtbot): settings = BaseSettings(tmp_path) roi = np.zeros((10, 10), dtype=np.uint8) - settings.image = Image(roi, (1, 1), axes_order="XY") + settings.image = Image(roi, spacing=(1, 1), axes_order="XY") roi[1:5, 1:5] = 1 roi[5:-1, 5:-1] = 3 with qtbot.waitSignal(settings.roi_changed): @@ -289,32 +289,36 @@ def test_channels(self, tmp_path, qtbot): settings = BaseSettings(tmp_path) assert not settings.has_channels assert settings.channels == 0 - settings.image = Image(np.zeros((10, 10, 2), dtype=np.uint8), (1, 1), axes_order="XYC") + settings.image = Image(np.zeros((10, 10, 2), dtype=np.uint8), spacing=(1, 1), axes_order="XYC") assert settings.has_channels assert settings.channels == 2 - settings.image = Image(np.zeros((10, 10, 1), dtype=np.uint8), (1, 1), axes_order="XYC") + settings.image = Image(np.zeros((10, 10, 1), dtype=np.uint8), spacing=(1, 1), axes_order="XYC") assert not settings.has_channels assert settings.channels == 1 def test_shape(self, tmp_path): settings = BaseSettings(tmp_path) assert settings.image_shape == () - settings.image = Image(np.zeros((10, 10, 2), dtype=np.uint8), (1, 1), axes_order="XYC") + settings.image = Image(np.zeros((10, 10, 2), dtype=np.uint8), spacing=(1, 1), axes_order="XYC") assert settings.image_shape == (1, 1, 10, 10) def test_verify_image(self): - assert BaseSettings.verify_image(Image(np.zeros((10, 10, 2), dtype=np.uint8), (1, 1), axes_order="XYC")) + assert BaseSettings.verify_image(Image(np.zeros((10, 10, 2), dtype=np.uint8), spacing=(1, 1), axes_order="XYC")) with pytest.raises(SwapTimeStackException): BaseSettings.verify_image( - Image(np.zeros((2, 10, 10), dtype=np.uint8), (1, 1, 1), axes_order="TXY"), silent=False + Image(np.zeros((2, 10, 10), dtype=np.uint8), spacing=(1, 1, 1), axes_order="TXY"), silent=False ) - im = BaseSettings.verify_image(Image(np.zeros((2, 10, 10), dtype=np.uint8), (1, 1, 1), axes_order="TXY")) + im = BaseSettings.verify_image( + Image(np.zeros((2, 10, 10), dtype=np.uint8), spacing=(1, 1, 1), axes_order="TXY") + ) assert not im.is_time assert im.times == 1 assert im.is_stack assert im.layers == 2 with pytest.raises(TimeAndStackException): - BaseSettings.verify_image(Image(np.zeros((2, 2, 10, 10), dtype=np.uint8), (1, 1, 1), axes_order="TZXY")) + BaseSettings.verify_image( + Image(np.zeros((2, 2, 10, 10), dtype=np.uint8), spacing=(1, 1, 1), axes_order="TZXY") + ) def test_algorithm_redirect(self, tmp_path): settings = BaseSettings(tmp_path) diff --git a/package/tests/test_PartSegCore/test_analysis_batch.py b/package/tests/test_PartSegCore/test_analysis_batch.py index 4c971b9f2..55a11183b 100644 --- a/package/tests/test_PartSegCore/test_analysis_batch.py +++ b/package/tests/test_PartSegCore/test_analysis_batch.py @@ -123,7 +123,7 @@ def _prepare_spacing_data(tmp_path): data[:, :, 2:-2, 2:-2] = 1 tifffile.imwrite(tmp_path / "test1.tiff", data) - image = Image(data, (1, 1, 1), axes_order="ZCYX", file_path=tmp_path / "test2.tiff") + image = Image(data, spacing=(1, 1, 1), axes_order="ZCYX", file_path=tmp_path / "test2.tiff") ImageWriter.save(image, image.file_path) @@ -134,7 +134,7 @@ def _prepare_mask_project_data(tmp_path): data[:, 6:8, 2:4] = 2 data[:, 6:8, 6:8] = 3 - image = Image(data, (1, 1, 1), axes_order="ZYX", file_path=tmp_path / "test.tiff") + image = Image(data, spacing=(1, 1, 1), axes_order="ZYX", file_path=tmp_path / "test.tiff") ImageWriter.save(image, image.file_path) roi = np.zeros(data.shape, dtype=np.uint8) diff --git a/package/tests/test_PartSegCore/test_image_adjustment.py b/package/tests/test_PartSegCore/test_image_adjustment.py index bd9aea93d..668f9e453 100644 --- a/package/tests/test_PartSegCore/test_image_adjustment.py +++ b/package/tests/test_PartSegCore/test_image_adjustment.py @@ -11,32 +11,32 @@ def get_flat_image(): data = np.zeros((1, 1, 10, 10), dtype=np.uint8) data[:, :, 2:-2, 2:-2] = 5 - return Image(data, (5, 5), axes_order="TZYX") + return Image(data, spacing=(5, 5), axes_order="TZYX") def get_cube_image(): data = np.zeros((1, 10, 10, 10), dtype=np.uint8) data[:, 2:-2, 2:-2, 2:-2] = 5 - return Image(data, (10, 5, 5), axes_order="TZYX") + return Image(data, spacing=(10, 5, 5), axes_order="TZYX") def get_cube_image_2ch(): data = np.zeros((1, 2, 10, 10, 10), dtype=np.uint8) data[:, 0, 2:-2, 2:-2, 2:-2] = 5 data[:, 1, 2:-2, 2:-2, 2:-2] = 10 - return Image(data, (10, 5, 5), axes_order="TCZYX") + return Image(data, spacing=(10, 5, 5), axes_order="TCZYX") def get_flat_image_up(): data = np.ones((1, 1, 10, 10), dtype=np.uint8) * 30 data[:, :, 2:-2, 2:-2] = 10 - return Image(data, (5, 5), axes_order="TZYX") + return Image(data, spacing=(5, 5), axes_order="TZYX") def get_cube_image_up(): data = np.ones((1, 10, 10, 10), dtype=np.uint8) * 30 data[:, 2:-2, 2:-2, 2:-2] = 10 - return Image(data, (10, 5, 5), axes_order="TZYX") + return Image(data, spacing=(10, 5, 5), axes_order="TZYX") class TestInterpolateImage: diff --git a/package/tests/test_PartSegCore/test_io.py b/package/tests/test_PartSegCore/test_io.py index c56319954..45028287d 100644 --- a/package/tests/test_PartSegCore/test_io.py +++ b/package/tests/test_PartSegCore/test_io.py @@ -77,8 +77,8 @@ def analysis_project() -> ProjectTuple: data[0, 0, 10:40, 40:50, 10:90] = 40 image = Image( data, - (10 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value]), - "", + spacing=(10 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value]), + file_path="", axes_order="CTZYX", ) mask = data[0, 0] > 0 @@ -122,8 +122,8 @@ def analysis_project_reversed() -> ProjectTuple: data = 100 - data image = Image( data, - (10 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value]), - "", + spacing=(10 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value], 5 / UNIT_SCALE[Units.nm.value]), + file_path="", axes_order="CTZYX", ) roi_info = ROIInfo(roi.squeeze()).fit_to_image(image) @@ -502,7 +502,7 @@ def test_mask_project_tuple(self): elem = HistoryElement.create(roi_info, mask, {}, mask_prop) proj = MaskProjectTuple( file_path="test_data.tiff", - image=Image(np.zeros((10, 10), dtype=np.uint8), (1, 1), "", axes_order="YX"), + image=Image(np.zeros((10, 10), dtype=np.uint8), spacing=(1, 1), file_path="", axes_order="YX"), mask=mask, roi_info=roi_info, history=[elem], diff --git a/package/tests/test_PartSegCore/test_mask_create.py b/package/tests/test_PartSegCore/test_mask_create.py index 40d5a714d..9c956775a 100644 --- a/package/tests/test_PartSegCore/test_mask_create.py +++ b/package/tests/test_PartSegCore/test_mask_create.py @@ -409,7 +409,7 @@ def test_mask_property_combinations( self, dilate, radius, fill_holes, max_holes_size, save_components, clip_to_mask, reversed_mask, old_mask ): mask = np.zeros((1, 6, 6, 15), dtype=np.uint8) - im = Image(data=mask.copy(), image_spacing=(3, 1, 1), file_path="", axes_order="TZYX") + im = Image(data=mask.copy(), spacing=(3, 1, 1), file_path="", axes_order="TZYX") mask[:, 1:-1, 1:-1, 2:5] = 1 mask[:, 2:-2, 2:-2, 3:4] = 0 mask[:, 1:-1, 1:-1, 6:9] = 2 diff --git a/package/tests/test_PartSegCore/test_measurements.py b/package/tests/test_PartSegCore/test_measurements.py index ac4978339..e18272926 100644 --- a/package/tests/test_PartSegCore/test_measurements.py +++ b/package/tests/test_PartSegCore/test_measurements.py @@ -65,7 +65,7 @@ def get_cube_array(): def get_cube_image(): - return Image(get_cube_array(), (100, 50, 50), "", axes_order="TZYX") + return Image(get_cube_array(), spacing=(100, 50, 50), file_path="", axes_order="TZYX") @pytest.fixture(name="cube_image") @@ -74,7 +74,7 @@ def cube_image_fixture(): def get_square_image(): - return Image(get_cube_array()[:, 25:26], (100, 50, 50), "", axes_order="TZYX") + return Image(get_cube_array()[:, 25:26], spacing=(100, 50, 50), file_path="", axes_order="TZYX") @pytest.fixture(name="square_image") @@ -90,7 +90,7 @@ def get_two_components_array(): def get_two_components_image(): - return Image(get_two_components_array(), (100, 50, 50), "", axes_order="TZYX") + return Image(get_two_components_array(), spacing=(100, 50, 50), file_path="", axes_order="TZYX") def get_two_component_mask(): @@ -844,7 +844,7 @@ def two_comp_img(): data = np.zeros((30, 30, 60), dtype=np.uint16) data[5:-5, 5:-5, 5:29] = 60 data[5:-5, 5:-5, 31:-5] = 50 - return Image(data, (100, 100, 50), "", axes_order="ZYX") + return Image(data, spacing=(100, 100, 50), file_path="", axes_order="ZYX") class TestDistanceMaskSegmentation: @@ -1255,7 +1255,7 @@ def test_cube_equal_volume_simple(self, nr, volume, diff_array): data = np.zeros((60, 100, 100), dtype=np.uint16) data[10:50, 20:80, 20:80] = 50 data[15:45, 30:70, 30:70] = 70 - image = Image(data, (2, 1, 1), "", axes_order="ZYX") + image = Image(data, spacing=(2, 1, 1), file_path="", axes_order="ZYX") mask1 = image.get_channel(0)[0] > 40 mask2 = image.get_channel(0)[0] > 60 result_scale = reduce(lambda x, y: x * y, image.voxel_size) @@ -1467,7 +1467,7 @@ def test_cube_equal_volume(self, nr, sum_val, diff_array): data = np.zeros((1, 60, 100, 100), dtype=np.uint16) data[0, 10:50, 20:80, 20:80] = 50 data[0, 15:45, 30:70, 30:70] = 70 - image = Image(data, (100, 50, 50), "", axes_order="TZYX") + image = Image(data, spacing=(100, 50, 50), file_path="", axes_order="TZYX") image.set_spacing(tuple(x / UNIT_SCALE[Units.nm.value] for x in image.spacing)) mask1 = image.get_channel(0)[0] > 40 mask2 = image.get_channel(0)[0] > 60 @@ -2161,7 +2161,7 @@ def test_base(self, roi_dist, new_roi_dist, roi_to_roi_extract): data[0, 2:-2, 2:-2, 2:-12] = 5 data[1, 2:-2, 2:-2, 12:-2] = 5 data[2, 2:-2, 2:-2, 2:-2] = 5 - image = Image(data, image_spacing=(1, 1, 1), axes_order="CZYX") + image = Image(data, spacing=(1, 1, 1), axes_order="CZYX") roi = (data[0] > 1).astype(np.uint8) res = DistanceROIROI.calculate_property( channel=data[2], @@ -2176,7 +2176,7 @@ def test_base(self, roi_dist, new_roi_dist, roi_to_roi_extract): ) assert res > 0 data[1, 3:-3, 3:-3, 3:-13] = 5 - image = Image(data, image_spacing=(1, 1, 1), axes_order="CZYX") + image = Image(data, spacing=(1, 1, 1), axes_order="CZYX") roi = (data[0] > 1).astype(np.uint8) res = DistanceROIROI.calculate_property( channel=data[2], @@ -2196,7 +2196,7 @@ def test_base_2d(self, roi_dist, new_roi_dist, roi_to_roi_extract): data[0, 2:-2, 2:-12] = 5 data[1, 2:-2, 12:-2] = 5 data[2, 2:-2, 2:-2] = 5 - image = Image(data, image_spacing=(1, 1, 1), axes_order="CYX") + image = Image(data, spacing=(1, 1, 1), axes_order="CYX") roi = (data[:1] > 1).astype(np.uint8) res = DistanceROIROI.calculate_property( channel=data[2:3], @@ -2218,7 +2218,7 @@ def test_base(self, roi_to_roi_extract): data[0, 2:-2, 2:-2, 2:-12] = 5 data[1, 2:-2, 2:-2, 12:-2] = 5 data[2, 2:-2, 2:-2, 2:-2] = 5 - image = Image(data, image_spacing=(100 * (10**-9),) * 3, axes_order="CZYX") + image = Image(data, spacing=(100 * (10**-9),) * 3, axes_order="CZYX") roi = (data[0] > 1).astype(np.uint8) kwargs = { "image": image, @@ -2233,7 +2233,7 @@ def test_base(self, roi_to_roi_extract): kwargs["distance"] = 1000 assert ROINeighbourhoodROI.calculate_property(**kwargs) == 1 data[1, 3:-3, 3:-3, 3:10] = 5 - image = Image(data, image_spacing=(100 * (10**-9),) * 3, axes_order="CZYX") + image = Image(data, spacing=(100 * (10**-9),) * 3, axes_order="CZYX") kwargs["image"] = image assert ROINeighbourhoodROI.calculate_property(**kwargs) == 2 kwargs["distance"] = 100 @@ -2244,7 +2244,7 @@ def test_base2d(self, roi_to_roi_extract): data[0, 2:-2, 2:-12] = 5 data[1, 2:-2, 12:-2] = 5 data[2, 2:-2, 2:-2] = 5 - image = Image(data, image_spacing=(100 * (10**-9),) * 2, axes_order="CYX") + image = Image(data, spacing=(100 * (10**-9),) * 2, axes_order="CYX") roi = (data[:1] > 1).astype(np.uint8) kwargs = { "image": image, @@ -2259,7 +2259,7 @@ def test_base2d(self, roi_to_roi_extract): kwargs["distance"] = 1000 assert ROINeighbourhoodROI.calculate_property(**kwargs) == 1 data[1, 3:-3, 3:10] = 5 - image = Image(data, image_spacing=(100 * (10**-9),) * 2, axes_order="CYX") + image = Image(data, spacing=(100 * (10**-9),) * 2, axes_order="CYX") kwargs["image"] = image assert ROINeighbourhoodROI.calculate_property(**kwargs) == 2 kwargs["distance"] = 100 @@ -2275,7 +2275,7 @@ def test_all_methods(method, dtype): roi = (data > 2).astype(np.uint8) mask = (data > 0).astype(np.uint8) roi_info = ROIInfo(roi) - image = Image(data, image_spacing=(1, 1, 1), axes_order="ZYX") + image = Image(data, spacing=(1, 1, 1), axes_order="ZYX") res = method.calculate_property( image=image, @@ -2313,7 +2313,7 @@ def test_per_component(method, area): data[1:-1, 6, 6] = 5 roi = (data[..., 0] > 2).astype(np.uint8) mask = (data[..., 0] > 0).astype(np.uint8) - image = Image(data, image_spacing=(10**-8,) * 3, axes_order="ZYXC") + image = Image(data, spacing=(10**-8,) * 3, axes_order="ZYXC") image.set_mask(mask, axes="ZYX") statistics = [ @@ -2394,7 +2394,7 @@ def test_per_mask_component(): mask = np.zeros(data.shape, dtype=np.uint8) mask[2:-2, 2:-2, 2:-12] = 1 mask[2:-2, 2:-2, 12:-2] = 2 - image = Image(data, image_spacing=(10**-8,) * 3, axes_order="ZYX", mask=mask) + image = Image(data, spacing=(10**-8,) * 3, axes_order="ZYX", mask=mask) profile = MeasurementProfile( name="test", chosen_fields=[ diff --git a/package/tests/test_PartSegCore/test_napari_plugins.py b/package/tests/test_PartSegCore/test_napari_plugins.py index 3a639c74d..8ce07a263 100644 --- a/package/tests/test_PartSegCore/test_napari_plugins.py +++ b/package/tests/test_PartSegCore/test_napari_plugins.py @@ -45,7 +45,7 @@ def test_project_to_layers_analysis(analysis_segmentation): def test_project_to_layers_roi(): data = np.zeros((1, 1, 10, 10, 10), dtype=np.uint8) - img = PImage(data, image_spacing=(1, 1, 1), name="ROI", axes_order="CTZYX") + img = PImage(data, spacing=(1, 1, 1), name="ROI", axes_order="CTZYX") proj = ProjectTuple(file_path="", image=img) res = project_to_layers(proj) assert len(res) == 1 diff --git a/package/tests/test_PartSegCore/test_segmentation.py b/package/tests/test_PartSegCore/test_segmentation.py index f0e50eb28..a5478413d 100644 --- a/package/tests/test_PartSegCore/test_segmentation.py +++ b/package/tests/test_PartSegCore/test_segmentation.py @@ -35,13 +35,13 @@ def get_two_parts_array(): def get_two_parts(): - return Image(get_two_parts_array(), (100, 50, 50), "", axes_order="TZYX") + return Image(get_two_parts_array(), spacing=(100, 50, 50), file_path="", axes_order="TZYX") def get_two_parts_reversed(): data = get_two_parts_array() data = 100 - data - return Image(data, (100, 50, 50), "", axes_order="TZYX") + return Image(data, spacing=(100, 50, 50), file_path="", axes_order="TZYX") def get_multiple_part_array(part_num): @@ -54,19 +54,19 @@ def get_multiple_part_array(part_num): def get_multiple_part(part_num): - return Image(get_multiple_part_array(part_num), (100, 50, 50), "", axes_order="TZYX") + return Image(get_multiple_part_array(part_num), spacing=(100, 50, 50), file_path="", axes_order="TZYX") def get_multiple_part_reversed(part_num): data = 100 - get_multiple_part_array(part_num) - return Image(data, (100, 50, 50), "", axes_order="TZYX") + return Image(data, spacing=(100, 50, 50), file_path="", axes_order="TZYX") def get_two_parts_side(): data = get_two_parts_array() data[0, 25, 40:45, 50] = 49 data[0, 25, 45:50, 51] = 49 - return Image(data, (100, 50, 50), "", axes_order="TZYX") + return Image(data, spacing=(100, 50, 50), file_path="", axes_order="TZYX") def get_two_parts_side_reversed(): @@ -74,7 +74,7 @@ def get_two_parts_side_reversed(): data[0, 25, 40:45, 50] = 49 data[0, 25, 45:50, 51] = 49 data = 100 - data - return Image(data, (100, 50, 50), "", axes_order="TZYX") + return Image(data, spacing=(100, 50, 50), file_path="", axes_order="TZYX") def empty(_s: str, _i: int): @@ -656,7 +656,7 @@ def get_image(): data = np.zeros((1, 50, 100, 100, 2), dtype=np.uint16) data[0, 10:40, 20:80, 20:60, 0] = 10 data[0, 10:40, 20:80, 40:80, 1] = 10 - return Image(data, (100, 50, 50), "", axes_order="TZYXC") + return Image(data, spacing=(100, 50, 50), file_path="", axes_order="TZYXC") @pytest.mark.parametrize("use_mask", [True, False]) def test_pipeline_simple(self, use_mask): diff --git a/package/tests/test_PartSegImage/test_image.py b/package/tests/test_PartSegImage/test_image.py index f06057bb6..596701508 100644 --- a/package/tests/test_PartSegImage/test_image.py +++ b/package/tests/test_PartSegImage/test_image.py @@ -5,8 +5,8 @@ import pytest from skimage.morphology import diamond -from PartSegImage import Channel, Image, ImageWriter, TiffImageReader -from PartSegImage.image import FRAME_THICKNESS +from PartSegImage import Channel, ChannelInfo, Image, ImageWriter, TiffImageReader +from PartSegImage.image import FRAME_THICKNESS, _hex_to_rgb class TestImageBase: @@ -59,7 +59,7 @@ def prepare_image_initial_shape(self, shape, channel): def test_fit_mask_simple(self): initial_shape = self.prepare_image_initial_shape([1, 10, 20, 20], 1) data = np.zeros(initial_shape, np.uint8) - image = self.image_class(data, (1, 1, 1), "", axes_order=self.image_class.axis_order) + image = self.image_class(data, spacing=(1, 1, 1), file_path="", axes_order=self.image_class.axis_order) mask = np.zeros((1, 10, 20, 20), np.uint8) mask[0, 2:-2, 4:-4, 4:-4] = 5 image.fit_mask_to_image(mask) @@ -67,7 +67,7 @@ def test_fit_mask_simple(self): def test_fit_mask_mapping_val(self): initial_shape = self.prepare_image_initial_shape([1, 10, 20, 20], 1) data = np.zeros(initial_shape, np.uint8) - image = self.image_class(data, (1, 1, 1), "", axes_order=self.image_class.axis_order) + image = self.image_class(data, spacing=(1, 1, 1), file_path="", axes_order=self.image_class.axis_order) mask = np.zeros((1, 10, 20, 20), np.uint16) mask[0, 2:-2, 4:-4, 4:10] = 5 mask[0, 2:-2, 4:-4, 11:-4] = 7 @@ -81,7 +81,7 @@ def test_fit_mask_mapping_val(self): def test_fit_mask_to_image_change_type(self): initial_shape = self.prepare_image_initial_shape([1, 30, 50, 50], 1) data = np.zeros(initial_shape, np.uint8) - image = self.image_class(data, (1, 1, 1), "", axes_order=self.image_class.axis_order) + image = self.image_class(data, spacing=(1, 1, 1), file_path="", axes_order=self.image_class.axis_order) mask_base = np.zeros(30 * 50 * 50, dtype=np.uint32) mask_base[:50] = np.arange(50, dtype=np.uint32) image.set_mask(np.reshape(mask_base, (1, 30, 50, 50))) @@ -110,14 +110,22 @@ def test_fit_mask_to_image_change_type(self): def test_image_mask(self): initial_shape = self.prepare_image_initial_shape([1, 10, 50, 50], 4) self.image_class( - np.zeros(initial_shape), (5, 5, 5), mask=np.zeros((10, 50, 50)), axes_order=self.image_class.axis_order + np.zeros(initial_shape), + spacing=(5, 5, 5), + mask=np.zeros((10, 50, 50)), + axes_order=self.image_class.axis_order, ) self.image_class( - np.zeros(initial_shape), (5, 5, 5), mask=np.zeros((1, 10, 50, 50)), axes_order=self.image_class.axis_order + np.zeros(initial_shape), + spacing=(5, 5, 5), + mask=np.zeros((1, 10, 50, 50)), + axes_order=self.image_class.axis_order, ) mask = np.zeros((1, 10, 50, 50)) mask[0, 2:-2] = 1 - im = self.image_class(np.zeros(initial_shape), (5, 5, 5), mask=mask, axes_order=self.image_class.axis_order) + im = self.image_class( + np.zeros(initial_shape), spacing=(5, 5, 5), mask=mask, axes_order=self.image_class.axis_order + ) assert np.all(im.mask == mask) def test_image_mask_exception(self): @@ -127,14 +135,14 @@ def test_image_mask_exception(self): with pytest.raises(ValueError, match="Wrong array shape"): self.image_class( np.zeros(data_shape), - (5, 5, 5), + spacing=(5, 5, 5), mask=np.zeros(data_shape[:-2] + (40,)), axes_order=self.image_class.axis_order, ) with pytest.raises(ValueError, match="Wrong array shape"): self.image_class( np.zeros(data_shape), - (5, 5, 5), + spacing=(5, 5, 5), mask=np.zeros(data_shape), axes_order=self.image_class.axis_order, ) @@ -142,86 +150,90 @@ def test_image_mask_exception(self): def test_reorder_axes(self): fixed_array = self.image_class.reorder_axes(np.zeros((10, 20)), axes="XY") assert fixed_array.shape == self.image_shape((10, 20), "XY") - fixed_image = self.image_class(np.zeros((10, 20)), image_spacing=(1, 1, 1), axes_order="XY") + fixed_image = self.image_class(np.zeros((10, 20)), spacing=(1, 1, 1), axes_order="XY") assert fixed_image.shape == self.image_shape((10, 20), "XY") with pytest.raises(ValueError, match="need to be equal to length of axes"): Image.reorder_axes(np.zeros((10, 20)), axes="XYZ") def test_reorder_axes_with_mask(self): - im = self.image_class(np.zeros((10, 50, 50, 4)), (5, 5, 5), mask=np.zeros((10, 50, 50)), axes_order="ZYXC") + im = self.image_class( + np.zeros((10, 50, 50, 4)), spacing=(5, 5, 5), mask=np.zeros((10, 50, 50)), axes_order="ZYXC" + ) assert im.shape == self.image_shape((10, 50, 50, 4), "ZYXC") # (1, 10, 50, 50, 4) - im = self.image_class(np.zeros((50, 10, 50, 4)), (5, 5, 5), mask=np.zeros((50, 10, 50)), axes_order="YZXC") + im = self.image_class( + np.zeros((50, 10, 50, 4)), spacing=(5, 5, 5), mask=np.zeros((50, 10, 50)), axes_order="YZXC" + ) assert im.shape == self.image_shape((50, 10, 50, 4), "YZXC") # (1, 10, 50, 50, 4) def test_wrong_dim_create(self): with pytest.raises(ValueError, match="Data should"): - self.image_class(np.zeros((10, 20)), (1, 1, 1), axes_order="XYZ") + self.image_class(np.zeros((10, 20)), spacing=(1, 1, 1), axes_order="XYZ") with pytest.raises(ValueError, match="Data should"): - self.image_class([np.zeros((10, 20)), np.zeros((10,))], (1, 1, 1), axes_order="XYZ") + self.image_class([np.zeros((10, 20)), np.zeros((10,))], spacing=(1, 1, 1), axes_order="XYZ") def test_get_dimension_number(self): assert ( self.image_class( - np.zeros((1, 10, 20, 20, 1), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((1, 10, 20, 20, 1), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_number() == 3 ) assert ( self.image_class( - np.zeros((1, 1, 20, 20, 1), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((1, 1, 20, 20, 1), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_number() == 2 ) assert ( self.image_class( - np.zeros((10, 1, 20, 20, 1), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((10, 1, 20, 20, 1), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_number() == 3 ) assert ( self.image_class( - np.zeros((1, 1, 20, 20, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((1, 1, 20, 20, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_number() == 2 ) assert ( self.image_class( - np.zeros((10, 1, 20, 20, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((10, 1, 20, 20, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_number() == 3 ) assert ( self.image_class( - np.zeros((10, 3, 20, 20, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((10, 3, 20, 20, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_number() == 4 ) def test_get_dimension_letters(self): assert self.image_class( - np.zeros((1, 10, 20, 20, 1), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((1, 10, 20, 20, 1), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_letters() == self.reorder_axes_letter("ZYX") assert self.image_class( - np.zeros((1, 1, 20, 20, 1), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((1, 1, 20, 20, 1), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_letters() == self.reorder_axes_letter("YX") assert self.image_class( - np.zeros((10, 1, 20, 20, 1), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((10, 1, 20, 20, 1), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_letters() == self.reorder_axes_letter("TYX") assert self.image_class( - np.zeros((1, 1, 20, 20, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((1, 1, 20, 20, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_letters() == self.reorder_axes_letter("YX") assert self.image_class( - np.zeros((10, 1, 20, 20, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((10, 1, 20, 20, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_letters() == self.reorder_axes_letter("TYX") assert self.image_class( - np.zeros((10, 3, 20, 20, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC" + np.zeros((10, 3, 20, 20, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" ).get_dimension_letters() == self.reorder_axes_letter("TZYX") def test_set_mask(self): initial_shape = self.prepare_image_initial_shape([1, 10, 20, 30], 1) image = self.image_class( - np.zeros(initial_shape, np.uint8), (1, 1, 1), "", axes_order=self.image_class.axis_order + np.zeros(initial_shape, np.uint8), spacing=(1, 1, 1), file_path="", axes_order=self.image_class.axis_order ) assert image.mask is None assert not image.has_mask @@ -241,32 +253,46 @@ def test_set_mask(self): assert image.mask is None def test_set_mask_reorder(self): - image = self.image_class(np.zeros((1, 10, 20, 30, 1), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 10, 20, 30, 1), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) image.set_mask(np.ones((30, 20, 10), np.uint8), "XYZ") assert image.mask.shape == self.mask_shape((1, 10, 20, 30), "TZYX") def test_get_image_for_save(self): - if "C" not in self.image_class.array_axis_order: + if "C" not in self.image_class.axis_order: pytest.skip("No channel axis") - image = self.image_class(np.zeros((1, 10, 3, 20, 30), np.uint8), (1, 1, 1), "", axes_order="TZCYX") + image = self.image_class( + np.zeros((1, 10, 3, 20, 30), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZCYX" + ) assert image.get_image_for_save().shape == (1, 10, 3, 20, 30) - image = self.image_class(np.zeros((1, 10, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 10, 20, 30, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) assert image.get_image_for_save().shape == (1, 10, 3, 20, 30) def test_get_image_for_save_no_channel(self): - image = self.image_class(np.zeros((1, 10, 20, 30), np.uint8), (1, 1, 1), "", axes_order="TZYX") + image = self.image_class( + np.zeros((1, 10, 20, 30), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYX" + ) assert image.get_image_for_save().shape == (1, 10, 1, 20, 30) - image = self.image_class(np.zeros((1, 10, 20, 30), np.uint8), (1, 1, 1), "", axes_order="TZYX") + image = self.image_class( + np.zeros((1, 10, 20, 30), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYX" + ) assert image.get_image_for_save().shape == (1, 10, 1, 20, 30) def test_get_mask_for_save(self): - image = self.image_class(np.zeros((1, 10, 3, 20, 30), np.uint8), (1, 1, 1), "", axes_order="TZCYX") + image = self.image_class( + np.zeros((1, 10, 3, 20, 30), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZCYX" + ) assert image.get_mask_for_save() is None image.set_mask(np.zeros((1, 10, 20, 30), np.uint8), axes="TZYX") assert image.get_mask_for_save().shape == (1, 10, 1, 20, 30) def test_image_properties(self): - image = self.image_class(np.zeros((1, 10, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 10, 20, 30, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) assert not image.has_mask assert not image.is_time assert image.is_stack @@ -277,7 +303,9 @@ def test_image_properties(self): assert image.plane_shape == (20, 30) def test_swap_time_and_stack(self): - image = self.image_class(np.zeros((1, 10, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 10, 20, 30, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) image2 = image.swap_time_and_stack() assert image.times == 1 assert image.layers == 10 @@ -286,7 +314,11 @@ def test_swap_time_and_stack(self): def test_get_channel(self): image = self.image_class( - np.zeros((1, 10, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC", channel_names=["a", "b", "c"] + np.zeros((1, 10, 20, 30, 3), np.uint8), + spacing=(1, 1, 1), + file_path="", + axes_order="TZYXC", + channel_info=[ChannelInfo(name="a"), ChannelInfo(name="b"), ChannelInfo(name="c")], ) assert image.has_channel(1) assert image.has_channel(Channel(1)) @@ -304,13 +336,17 @@ def test_get_channel(self): image.get_channel(4) def test_get_layer(self): - image = self.image_class(np.zeros((1, 10, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 10, 20, 30, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) with pytest.deprecated_call(): layer = image.get_layer(0, 5) assert layer.shape == self.needed_layer_shape((20, 30, 3), "YXC", "TZ") def test_spacing(self): - image = self.image_class(np.zeros((1, 10, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 10, 20, 30, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) assert image.spacing == (1, 1, 1) assert image.voxel_size == (1, 1, 1) image.set_spacing((1, 2, 3)) @@ -326,7 +362,9 @@ def test_spacing(self): assert image.voxel_size == (1, 2, 3) def test_spacing_2d(self): - image = self.image_class(np.zeros((1, 1, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 1, 20, 30, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) assert image.spacing == (1, 1) assert image.voxel_size == (1, 1) image.set_spacing((1, 2)) @@ -335,7 +373,9 @@ def test_spacing_2d(self): def test_cut_image(self): # TODO add tests for more irregular shape - image = self.image_class(np.zeros((1, 10, 20, 30, 3), np.uint8), (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class( + np.zeros((1, 10, 20, 30, 3), np.uint8), spacing=(1, 1, 1), file_path="", axes_order="TZYXC" + ) mask1 = np.zeros((1, 10, 20, 30), np.uint8) mask1[0, 2:-2, 2:9, 2:-2] = 1 mask1[0, 2:-2, 11:-2, 2:-2] = 2 @@ -371,17 +411,17 @@ def test_get_ranges(self): data[..., :10, 0] = 2 data[..., :10, 1] = 20 data[..., :10, 2] = 9 - image = self.image_class(data, (1, 1, 1), "", axes_order="TZYXC") + image = self.image_class(data, spacing=(1, 1, 1), file_path="", axes_order="TZYXC") assert len(image.get_ranges()) == 3 assert image.get_ranges() == [(0, 2), (0, 20), (0, 9)] def test_get_um_spacing(self): image = self.image_class( - np.zeros((1, 10, 20, 30, 3), np.uint8), (10**-6, 10**-6, 10**-6), "", axes_order="TZYXC" + np.zeros((1, 10, 20, 30, 3), np.uint8), spacing=(10**-6, 10**-6, 10**-6), file_path="", axes_order="TZYXC" ) assert image.get_um_spacing() == (1, 1, 1) image = self.image_class( - np.zeros((1, 1, 20, 30, 3), np.uint8), (10**-6, 10**-6, 10**-6), "", axes_order="TZYXC" + np.zeros((1, 1, 20, 30, 3), np.uint8), spacing=(10**-6, 10**-6, 10**-6), file_path="", axes_order="TZYXC" ) assert image.get_um_spacing() == (1, 1) @@ -392,7 +432,7 @@ def test_save(self, tmp_path): data[..., :10, 0] = 2 data[..., :10, 1] = 20 data[..., :10, 2] = 9 - image = self.image_class(data, (10**-6, 10**-6, 10**-6), "", axes_order="TZYXC") + image = self.image_class(data, spacing=(10**-6, 10**-6, 10**-6), file_path="", axes_order="TZYXC") mask = np.zeros((10, 20, 30), np.uint8) mask[..., 2:12] = 1 image.set_mask(mask, "ZYX") @@ -407,7 +447,7 @@ def test_save(self, tmp_path): def test_axes_pos(self): data = np.zeros((10, 10), np.uint8) - image = self.image_class(data, (1, 1), axes_order="XY") + image = self.image_class(data, spacing=(1, 1), axes_order="XY") assert image.x_pos == image.array_axis_order.index("X") assert image.y_pos == image.array_axis_order.index("Y") assert image.time_pos == image.array_axis_order.index("T") @@ -415,13 +455,13 @@ def test_axes_pos(self): def test_get_axis_positions(self): data = np.zeros((10, 10), np.uint8) - image = self.image_class(data, (1, 1), axes_order="XY") + image = self.image_class(data, spacing=(1, 1), axes_order="XY") assert set(image.get_axis_positions()) == set(image.axis_order) assert len(set(image.get_axis_positions().values())) == len(image.axis_order) def test_get_data(self): data = np.zeros((10, 10), np.uint8) - image = self.image_class(data, (1, 1), axes_order="XY") + image = self.image_class(data, spacing=(1, 1), axes_order="XY") assert np.all(image.get_data() == data) @@ -478,8 +518,8 @@ class TestInheritanceAdditionalAxesImage(TestImageBase): class TestMergeImage: @pytest.mark.parametrize("check_dtype", [np.uint8, np.uint16, np.uint32, np.float16, np.float32, np.float64]) def test_merge_chanel(self, check_dtype): - image1 = Image(data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", image_spacing=(1, 1, 1)) - image2 = Image(data=np.ones((3, 10, 10), dtype=check_dtype), axes_order="ZXY", image_spacing=(1, 1, 1)) + image1 = Image(data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", spacing=(1, 1, 1)) + image2 = Image(data=np.ones((3, 10, 10), dtype=check_dtype), axes_order="ZXY", spacing=(1, 1, 1)) res_image = image1.merge(image2, "C") assert res_image.channels == 2 assert np.all(res_image.get_channel(0) == 0) @@ -488,16 +528,16 @@ def test_merge_chanel(self, check_dtype): assert res_image.dtype == check_dtype def test_merge_fail(self): - image1 = Image(data=np.zeros((4, 10, 10), dtype=np.uint8), axes_order="ZXY", image_spacing=(1, 1, 1)) - image2 = Image(data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", image_spacing=(1, 1, 1)) + image1 = Image(data=np.zeros((4, 10, 10), dtype=np.uint8), axes_order="ZXY", spacing=(1, 1, 1)) + image2 = Image(data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", spacing=(1, 1, 1)) with pytest.raises(ValueError, match="Shape of arrays are different"): image1.merge(image2, "C") @pytest.mark.parametrize("axis_mark", Image.axis_order) def test_merge_different_axes(self, axis_mark): base_shape = (1, 1, 3, 10, 10) - image1 = Image(data=np.zeros(base_shape, dtype=np.uint8), axes_order="CTZXY", image_spacing=(1, 1, 1)) - image2 = Image(data=np.ones(base_shape, dtype=np.uint8), axes_order="CTZXY", image_spacing=(1, 1, 1)) + image1 = Image(data=np.zeros(base_shape, dtype=np.uint8), axes_order="CTZXY", spacing=(1, 1, 1)) + image2 = Image(data=np.ones(base_shape, dtype=np.uint8), axes_order="CTZXY", spacing=(1, 1, 1)) res_image = image1.merge(image2, axis_mark) res_data = res_image.get_data() new_shape_li = list(base_shape) @@ -508,20 +548,20 @@ def test_merge_channel_name(self): image1 = Image( data=np.zeros((2, 4, 10, 10), dtype=np.uint8), axes_order="CZXY", - image_spacing=(1, 1, 1), - channel_names=["channel 1", "channel 3"], + spacing=(1, 1, 1), + channel_info=[ChannelInfo(name="channel 1"), ChannelInfo(name="channel 3")], ) image2 = Image( data=np.zeros((4, 10, 10), dtype=np.uint8), axes_order="ZXY", - image_spacing=(1, 1, 1), - channel_names=["channel 1"], + spacing=(1, 1, 1), + channel_info=[ChannelInfo(name="channel 1")], ) image3 = Image( data=np.zeros((4, 10, 10), dtype=np.uint8), axes_order="ZXY", - image_spacing=(1, 1, 1), - channel_names=["channel 5"], + spacing=(1, 1, 1), + channel_info=[ChannelInfo(name="channel 5")], ) res_image = image1.merge(image2, "C") assert res_image.channel_names == ["channel 1", "channel 3", "channel 1 (1)"] @@ -532,37 +572,35 @@ def test_channel_name_form_str(self): image1 = Image( data=np.zeros((1, 4, 10, 10), dtype=np.uint8), axes_order="CZXY", - image_spacing=(1, 1, 1), - channel_names=["channel 1"], + spacing=(1, 1, 1), + channel_info=[ChannelInfo(name="channel 1")], ) assert image1.channel_names == ["channel 1"] image2 = Image( data=np.zeros((2, 4, 10, 10), dtype=np.uint8), axes_order="CZXY", - image_spacing=(1, 1, 1), - channel_names="channel", + spacing=(1, 1, 1), + channel_info=[ChannelInfo(name="channel")], ) assert image2.channel_names == ["channel", "channel 2"] image3 = Image( data=np.zeros((2, 4, 10, 10), dtype=np.uint8), axes_order="CZXY", - image_spacing=(1, 1, 1), - channel_names="channel 1", + spacing=(1, 1, 1), + channel_info=[ChannelInfo(name="channel 1")], ) assert image3.channel_names == ["channel 1", "channel 2"] def test_different_axes_order(self): - image1 = Image(data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", image_spacing=(1, 1, 1)) - image2 = ChangeChannelPosImage( - data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", image_spacing=(1, 1, 1) - ) + image1 = Image(data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", spacing=(1, 1, 1)) + image2 = ChangeChannelPosImage(data=np.zeros((3, 10, 10), dtype=np.uint8), axes_order="ZXY", spacing=(1, 1, 1)) res_image = image1.merge(image2, "C") assert res_image.channels == 2 assert isinstance(res_image, Image) assert isinstance(image2.merge(image1, "C"), ChangeChannelPosImage) def test_get_data_by_axis(self): - image = Image(data=np.zeros((2, 3, 10, 10), dtype=np.uint8), axes_order="CZXY", image_spacing=(1, 1, 1)) + image = Image(data=np.zeros((2, 3, 10, 10), dtype=np.uint8), axes_order="CZXY", spacing=(1, 1, 1)) assert image.get_data_by_axis(c=0).shape == (1, 3, 10, 10) assert image.get_data_by_axis(C=1).shape == (1, 3, 10, 10) assert image.get_data_by_axis(z=0).shape == (2, 1, 10, 10) @@ -580,7 +618,7 @@ def test_cut_with_roi(): mask[0:11, 0:11][diam > 0] = 1 mask[6:17, 6:17][diam > 0] = 2 mask[12:23, 12:23][diam > 0] = 3 - image = Image(data, (1, 1), axes_order="CXY") + image = Image(data, spacing=(1, 1), axes_order="CXY") for i in range(1, 4): cut_image, cut_mask = image._cut_with_roi(mask == i, replace_mask=True, frame=2) assert cut_image[0].shape == (1, 1, 15, 15) @@ -589,12 +627,81 @@ def test_cut_with_roi(): def test_str_and_repr_mask_presence(): - image = Image(np.zeros((10, 10), np.uint8), (1, 1), "test", axes_order="XY") + image = Image(np.zeros((10, 10), np.uint8), spacing=(1, 1), file_path="test", axes_order="XY") assert "mask: False" in str(image) assert "mask=False" in repr(image) - assert "coloring: None" in str(image) + assert "coloring: ['red']" in str(image) image.set_mask(np.zeros((10, 10), np.uint8)) assert "mask: True" in str(image) assert "mask=True" in repr(image) + + +def test_image_to_spacingspacing_rename(): + with pytest.warns(match="Argument image_spacing is deprecated since 0.15.4. Use spacing instead"): + img = Image(np.zeros((10, 10), np.uint8), image_spacing=(2, 3), file_path="test", axes_order="XY") + assert img.spacing == (2, 3) + + +def test_image_positional_to_named(): + with pytest.warns(match="Since PartSeg 0.15.4 all arguments, except first one, should be named"): + img = Image(np.zeros((10, 10), np.uint8), (2, 3), "test", axes_order="XY") + assert img.spacing == (2, 3) + assert img.file_path == "test" + + +def test_merge_channel_props(): + with pytest.warns(match="Using channel_names, default_coloring and ranges is deprecated since PartSeg 0.15.4"): + img = Image( + data=np.zeros((2, 4, 10, 10), dtype=np.uint8), + axes_order="CZXY", + spacing=(1, 1, 1), + channel_names=["channel 2", "strange"], + default_coloring=["red", (128, 255, 0)], + ranges=[(0, 255), (0, 128)], + ) + + assert img.channel_names == ["channel 2", "strange"] + assert img.default_coloring[0] == "red" + assert tuple(img.default_coloring[1]) == (128, 255, 0) + assert img.ranges == [(0, 255), (0, 128)] + + +@pytest.mark.parametrize( + ("channel_name", "default_coloring", "ranges"), + [ + ("channel", None, None), + ( + None, + [ + "blue", + ], + None, + ), + (None, None, [(0, 128)]), + (["channel"], [], []), + ], +) +def test_merge_channel_props_with_none(channel_name, default_coloring, ranges): + with pytest.warns(match="Using channel_names, default_coloring and ranges is deprecated since PartSeg 0.15.4"): + img = Image( + data=np.zeros((1, 4, 10, 10), dtype=np.uint8), + axes_order="CZXY", + spacing=(1, 1, 1), + channel_names=channel_name, + default_coloring=default_coloring, + ranges=ranges, + ) + assert img.default_coloring == (default_coloring or ["red"]) + assert img.ranges == (ranges or [(0, 0)]) + assert img.channel_names == (["channel"] if channel_name else ["channel 1"]) + + +def test_hex_to_rgb(): + assert _hex_to_rgb("#ff0000") == (255, 0, 0) + assert _hex_to_rgb("#00FF00") == (0, 255, 0) + assert _hex_to_rgb("#b00") == (187, 0, 0) + assert _hex_to_rgb("#B00") == (187, 0, 0) + with pytest.raises(ValueError, match="Invalid hex code format"): + _hex_to_rgb("#b000") diff --git a/package/tests/test_PartSegImage/test_image_writer.py b/package/tests/test_PartSegImage/test_image_writer.py index 477115a40..bc7190442 100644 --- a/package/tests/test_PartSegImage/test_image_writer.py +++ b/package/tests/test_PartSegImage/test_image_writer.py @@ -4,6 +4,7 @@ import tifffile from lxml import etree # nosec +from PartSegImage import ChannelInfo from PartSegImage.image import Image from PartSegImage.image_reader import TiffImageReader from PartSegImage.image_writer import IMAGEJImageWriter, ImageWriter @@ -15,7 +16,7 @@ def ome_xml(bundle_test_dir): def test_scaling(tmp_path): - image = Image(np.zeros((10, 50, 50), dtype=np.uint8), (30, 0.1, 0.1), axes_order="ZYX") + image = Image(np.zeros((10, 50, 50), dtype=np.uint8), spacing=(30, 0.1, 0.1), axes_order="ZYX") ImageWriter.save(image, tmp_path / "image.tif") read_image = TiffImageReader.read_image(tmp_path / "image.tif") assert np.all(np.isclose(image.spacing, read_image.spacing)) @@ -28,7 +29,7 @@ def test_save_mask(tmp_path): mask = np.array(data > 0).astype(np.uint8) - image = Image(data, (0.4, 0.1, 0.1), mask=mask, axes_order="ZYX") + image = Image(data, spacing=(0.4, 0.1, 0.1), mask=mask, axes_order="ZYX") ImageWriter.save_mask(image, tmp_path / "mask.tif") read_mask = TiffImageReader.read_image(tmp_path / "mask.tif") @@ -43,9 +44,9 @@ def test_ome_save(tmp_path, bundle_test_dir, ome_xml, z_size): data = np.zeros((z_size, 20, 20, 2), dtype=np.uint8) image = Image( data, - image_spacing=(27 * 10**-6, 6 * 10**-6, 6 * 10**-6), + spacing=(27 * 10**-6, 6 * 10**-6, 6 * 10**-6), axes_order="ZYXC", - channel_names=["a", "b"], + channel_info=[ChannelInfo(name="a"), ChannelInfo(name="b")], shift=(10, 9, 8), name="Test", ) @@ -73,14 +74,14 @@ def test_ome_save(tmp_path, bundle_test_dir, ome_xml, z_size): def test_scaling_imagej(tmp_path): - image = Image(np.zeros((10, 50, 50), dtype=np.uint8), (30, 0.1, 0.1), axes_order="ZYX") + image = Image(np.zeros((10, 50, 50), dtype=np.uint8), spacing=(30, 0.1, 0.1), axes_order="ZYX") IMAGEJImageWriter.save(image, tmp_path / "image.tif") read_image = TiffImageReader.read_image(tmp_path / "image.tif") assert np.all(np.isclose(image.spacing, read_image.spacing)) def test_scaling_imagej_fail(tmp_path): - image = Image(np.zeros((10, 50, 50), dtype=np.float64), (30, 0.1, 0.1), axes_order="ZYX") + image = Image(np.zeros((10, 50, 50), dtype=np.float64), spacing=(30, 0.1, 0.1), axes_order="ZYX") with pytest.raises(ValueError, match="Data type float64"): IMAGEJImageWriter.save(image, tmp_path / "image.tif") @@ -96,6 +97,32 @@ def test_imagej_write_all_metadata(tmp_path, data_test_dir): npt.assert_array_equal(image2.default_coloring, image.default_coloring) +def test_imagej_save_color(tmp_path): + data = np.zeros((4, 20, 20), dtype=np.uint8) + data[:, 2:-2, 2:-2] = 20 + img = Image( + data, + spacing=(0.4, 0.1, 0.1), + axes_order="CYX", + channel_info=[ + ChannelInfo(name="ch1", color_map="blue", contrast_limits=(0, 20)), + ChannelInfo(name="ch2", color_map="#FFAA00", contrast_limits=(0, 30)), + ChannelInfo(name="ch3", color_map="#FB1", contrast_limits=(0, 25)), + ChannelInfo(name="ch4", color_map=(0, 180, 0), contrast_limits=(0, 22)), + ], + ) + assert img.get_colors()[:3] == ["blue", "#FFAA00", "#FB1"] + assert tuple(img.get_colors()[3]) == (0, 180, 0) + IMAGEJImageWriter.save(img, tmp_path / "image.tif") + image2 = TiffImageReader.read_image(tmp_path / "image.tif") + assert image2.channel_names == ["ch1", "ch2", "ch3", "ch4"] + assert image2.ranges == [(0, 20), (0, 30), (0, 25), (0, 22)] + assert tuple(image2.default_coloring[0][:, -1]) == (0, 0, 255) + assert tuple(image2.default_coloring[1][:, -1]) == (255, 170, 0) + assert tuple(image2.default_coloring[2][:, -1]) == (255, 187, 17) + assert tuple(image2.default_coloring[3][:, -1]) == (0, 180, 0) + + def test_save_mask_imagej(tmp_path): data = np.zeros((10, 40, 40), dtype=np.uint8) data[1:-1, 1:-1, 1:-1] = 1 @@ -103,7 +130,7 @@ def test_save_mask_imagej(tmp_path): mask = np.array(data > 0).astype(np.uint8) - image = Image(data, (0.4, 0.1, 0.1), mask=mask, axes_order="ZYX") + image = Image(data, spacing=(0.4, 0.1, 0.1), mask=mask, axes_order="ZYX") IMAGEJImageWriter.save_mask(image, tmp_path / "mask.tif") read_mask = TiffImageReader.read_image(tmp_path / "mask.tif")