From cfba642d8b94f39ee6d9187d767408b17937cda8 Mon Sep 17 00:00:00 2001 From: vqdang <24943262+vqdang@users.noreply.github.com> Date: Mon, 29 Jan 2024 15:33:57 +0000 Subject: [PATCH 01/36] UPD: fix bound --- tiatoolbox/wsicore/wsireader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index 49e9180fa..ee0dfecaa 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -4071,7 +4071,7 @@ class docstrings for more information. # but base image is of different scale) ( read_level, - _, + bounds_at_read_level, _, post_read_scale, ) = self._find_read_bounds_params( @@ -4083,7 +4083,7 @@ class docstrings for more information. # Find parameters for optimal read ( read_level, - _, + bounds_at_read_level, size_at_requested, post_read_scale, ) = self._find_read_bounds_params( @@ -4094,7 +4094,7 @@ class docstrings for more information. im_region = utils.image.sub_pixel_read( image=self.level_arrays[read_level], - bounds=bounds_at_baseline, + bounds=bounds_at_read_level, output_size=size_at_requested, interpolation=interpolation, pad_mode=pad_mode, From 6fdf9e704f3fcd4ab685e239b0b0d269539df40b Mon Sep 17 00:00:00 2001 From: Abdol A Date: Mon, 13 May 2024 15:04:45 +0100 Subject: [PATCH 02/36] =?UTF-8?q?=E2=9C=85=20Add=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_wsireader.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index e8d0f28da..921b26d04 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -974,6 +974,27 @@ def test_read_bounds_level_consistency_jp2(sample_jp2: Path) -> None: read_bounds_level_consistency(wsi, bounds) +def test_read_bounds_tiff_wsi_reader(sample_svs: Path) -> None: + """Test read_bounds with TIFFWSIReader.""" + reader = TIFFWSIReader(sample_svs) + slide_dimensions = reader.info.slide_dimensions + bounds = (0, 0, *slide_dimensions) + resolution = reader.info.mpp + coord_spaces = ["baseline", "resolution"] + for coord_space in coord_spaces: + im_region = reader.read_bounds( + bounds=bounds, + resolution=resolution, + units="mpp", + interpolation="nearest", + coord_space=coord_space, + ) + expected_output_shape = tuple(np.round(slide_dimensions[::-1]).astype(int)) + assert isinstance(im_region, np.ndarray) + assert im_region.shape[2] == 3 + assert im_region.shape[:2] == pytest.approx(expected_output_shape, abs=1) + + def test_wsireader_save_tiles(sample_svs: Path, tmp_path: Path) -> None: """Test for save_tiles in wsireader as a python function.""" tmp_path = Path(tmp_path) From 609469d87b02865ec27b309646057e7451cf801b Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 21 Jun 2024 18:31:02 +0100 Subject: [PATCH 03/36] initial draft multichannel reading --- tiatoolbox/utils/postproc_defs.py | 68 +++++++++ tiatoolbox/visualization/bokeh_app/main.py | 21 ++- tiatoolbox/wsicore/wsireader.py | 158 +++++++++++++++++---- 3 files changed, 217 insertions(+), 30 deletions(-) create mode 100644 tiatoolbox/utils/postproc_defs.py diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py new file mode 100644 index 000000000..91e5fc6ec --- /dev/null +++ b/tiatoolbox/utils/postproc_defs.py @@ -0,0 +1,68 @@ +"""Module to provide postprocessing classes.""" + +from __future__ import annotations + +import colorsys + +import numpy as np + + +class MultichannelToRGB: + """Class to convert multi-channel images to RGB images.""" + + def __init__( + self: MultichannelToRGB, + default_colors: list[tuple[float, float, float]] | None = None, + ) -> None: + """Initialize the MultichannelToRGB converter. + + Args: + default_colors: List of default RGB colors for each channel. If not + provided, colors will be auto-generated using the HSV color space. + + """ + self.default_colors = default_colors + if self.default_colors is not None: + self.default_colors = np.array(self.default_colors, dtype=np.float32) + + def generate_colors(self: MultichannelToRGB, n_channels: int) -> np.ndarray: + """Generate a set of visually distinct colors. + + Args: + n_channels (int): Number of channels/colors to generate + + Returns: + np.ndarray: Array of RGB colors + + """ + return np.array( + [colorsys.hsv_to_rgb(i / n_channels, 1, 1) for i in range(n_channels)], + dtype=np.float32, + ) + + def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: + """Convert a multi-channel image to an RGB image. + + Args: + image (np.ndarray): Input image of shape (H, W, N) + + Returns: + np.ndarray: RGB image of shape (H, W, 3) + + """ + n = image.shape[2] + + if n < 5: # noqa: PLR2004 + # assume already rgb(a) so just return image + return image + + if self.default_colors is None: + colors = self.generate_colors(n) + else: + colors = self.default_colors + + # Convert to RGB image + rgb_image = np.einsum("hwn,nc->hwc", image, colors, optimize=True) + + # Clip to ensure in valid range and return + return np.clip(rgb_image, 0, 255).astype(np.uint8) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index bdd612e3a..b58ae529e 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -725,7 +725,16 @@ def populate_slide_list(slide_folder: Path, search_txt: str | None = None) -> No """Populate the slide list with the available slides.""" file_list = [] len_slidepath = len(slide_folder.parts) - for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.jpg", "*.png", "*.tif"]: + for ext in [ + "*.svs", + "*.ndpi", + "*.tiff", + "*.mrxs", + "*.jpg", + "*.png", + "*.tif", + "*.qptiff", + ]: file_list.extend(list(Path(slide_folder).glob(str(Path("*") / ext)))) file_list.extend(list(Path(slide_folder).glob(ext))) if search_txt is None: @@ -2086,7 +2095,15 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]: # Set initial slide to first one in base folder slide_list = [] - for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.png", "*.jpg"]: + for ext in [ + "*.svs", + "*.ndpi", + "*.tiff", + "*.mrxs", + "*.png", + "*.jpg", + "*.qptiff", + ]: slide_list.extend(list(doc_config["slide_folder"].glob(ext))) slide_list.extend( list(doc_config["slide_folder"].glob(str(Path("*") / ext))), diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index 00e8b7673..f6532cd7d 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -24,6 +24,7 @@ from tiatoolbox import logger, utils from tiatoolbox.annotation import AnnotationStore, SQLiteStore +from tiatoolbox.utils import postproc_defs from tiatoolbox.utils.env_detection import pixman_warning from tiatoolbox.utils.exceptions import FileNotSupportedError from tiatoolbox.utils.magic import is_sqlite3 @@ -258,7 +259,10 @@ def np_virtual_wsi( def _handle_tiff_wsi( - input_path: Path, mpp: tuple[Number, Number] | None, power: Number | None + input_path: Path, + mpp: tuple[Number, Number] | None, + power: Number | None, + post_proc: str | callable | None, ) -> TIFFWSIReader | OpenSlideWSIReader | None: """Handle TIFF WSI cases. @@ -271,6 +275,8 @@ def _handle_tiff_wsi( power (:obj:`float` or :obj:`None`, optional): The objective power of the WSI. If not provided, the power is approximated from the MPP. + post_proc (str | callable | None): + Post-processing function to apply to the image. Returns: OpenSlideWSIReader | TIFFWSIReader | None: @@ -280,11 +286,13 @@ def _handle_tiff_wsi( """ if openslide.OpenSlide.detect_format(input_path) is not None: try: - return OpenSlideWSIReader(input_path, mpp=mpp, power=power) + return OpenSlideWSIReader( + input_path, mpp=mpp, power=power, post_proc=post_proc + ) except openslide.OpenSlideError: pass if is_tiled_tiff(input_path): - return TIFFWSIReader(input_path, mpp=mpp, power=power) + return TIFFWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc) return None @@ -308,6 +316,10 @@ class WSIReader: power (:obj:`float` or :obj:`None`, optional): The objective power of the WSI. If not provided, the power is approximated from the MPP. + post_proc (str | callable | None): + Post-processing function to apply to the image. If None, + no post-processing is applied. If 'auto', the post-processing + function is automatically selected based on the reader type. """ @@ -316,6 +328,7 @@ def open( # noqa: PLR0911 input_img: str | Path | np.ndarray | WSIReader, mpp: tuple[Number, Number] | None = None, power: Number | None = None, + post_proc: str | callable | None = "auto", **kwargs: dict, ) -> WSIReader: """Return an appropriate :class:`.WSIReader` object. @@ -334,6 +347,10 @@ def open( # noqa: PLR0911 (x, y) tuple of the MPP in the units of the input image. power (float): Objective power of the input image. + post_proc (str | callable | None): + Post-processing function to apply to the image. If None, + no post-processing is applied. If 'auto', the post-processing + function is automatically selected based on the reader type. kwargs (dict): Key-word arguments. @@ -353,7 +370,9 @@ def open( # noqa: PLR0911 msg, ) if isinstance(input_img, np.ndarray): - return VirtualWSIReader(input_img, mpp=mpp, power=power) + return VirtualWSIReader( + input_img, mpp=mpp, power=power, post_proc=post_proc + ) if isinstance(input_img, WSIReader): return input_img @@ -364,12 +383,13 @@ def open( # noqa: PLR0911 # Handle special cases first (DICOM, Zarr/NGFF, OME-TIFF) if is_dicom(input_path): - return DICOMWSIReader(input_path, mpp=mpp, power=power) + return DICOMWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc) _, _, suffixes = utils.misc.split_path_name_ext(input_path) last_suffix = suffixes[-1] if last_suffix == ".db": + kwargs["post_proc"] = post_proc return AnnotationStoreReader(input_path, **kwargs) if last_suffix in (".zarr",): @@ -381,10 +401,15 @@ def open( # noqa: PLR0911 return NGFFWSIReader(input_path, mpp=mpp, power=power) if suffixes[-2:] in ([".ome", ".tiff"],): - return TIFFWSIReader(input_path, mpp=mpp, power=power) + return TIFFWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc) + + if last_suffix == ".qptiff": + return TIFFWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc) if last_suffix in (".tif", ".tiff"): - tiff_wsi = _handle_tiff_wsi(input_path, mpp=mpp, power=power) + tiff_wsi = _handle_tiff_wsi( + input_path, mpp=mpp, power=power, post_proc=post_proc + ) if tiff_wsi is not None: return tiff_wsi @@ -396,7 +421,7 @@ def open( # noqa: PLR0911 return virtual_wsi # Try openslide last - return OpenSlideWSIReader(input_path, mpp=mpp, power=power) + return OpenSlideWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc) @staticmethod def verify_supported_wsi(input_path: Path) -> None: @@ -429,6 +454,7 @@ def verify_supported_wsi(input_path: Path) -> None: ".jpeg", ".zarr", ".db", + ".qptiff", ]: msg = f"File {input_path} is not a supported file format." raise FileNotSupportedError( @@ -440,6 +466,7 @@ def __init__( input_img: str | Path | np.ndarray | AnnotationStore, mpp: tuple[Number, Number] | None = None, power: Number | None = None, + post_proc: callable | None = None, ) -> None: """Initialize :class:`WSIReader`.""" if isinstance(input_img, (np.ndarray, AnnotationStore)): @@ -464,6 +491,7 @@ def __init__( msg = "`power` must be a number." raise TypeError(msg) self._manual_power = power + self.post_proc = self.get_post_proc(post_proc) @property def info(self: WSIReader) -> WSIMeta: @@ -495,6 +523,35 @@ def info(self: WSIReader, meta: WSIMeta) -> None: """ self._m_info = meta + def get_post_proc(self: WSIReader, post_proc: str | callable | None) -> callable: + """Get the post-processing function. + + Args: + post_proc (str | callable | None): + Post-processing function to apply to the image. If auto, + will use no post_proc unless reader is TIFF or Virtual Reader, + in which case it will use MultichannelToRGB. + + Returns: + callable: + Post-processing function. + + """ + if callable(post_proc): + return post_proc + if post_proc is None: + return None + if post_proc == "auto": + # if its TIFFWSIReader or VirtualWSIReader, return fn to + # allow multichannel, else return None + if isinstance(self, (TIFFWSIReader, VirtualWSIReader)): + return postproc_defs.MultichannelToRGB() + return None + if isinstance(post_proc, str) and hasattr(postproc_defs, post_proc): + return getattr(postproc_defs, post_proc)() + msg = f"Invalid post-processing function: {post_proc}" + raise ValueError(msg) + def _info(self: WSIReader) -> WSIMeta: """WSI metadata internal getter used to update info property. @@ -1724,9 +1781,10 @@ def __init__( input_img: str | Path | np.ndarray, mpp: tuple[Number, Number] | None = None, power: Number | None = None, + post_proc: str | callable | None = "auto", ) -> None: """Initialize :class:`OpenSlideWSIReader`.""" - super().__init__(input_img=input_img, mpp=mpp, power=power) + super().__init__(input_img=input_img, mpp=mpp, power=power, post_proc=post_proc) self.openslide_wsi = openslide.OpenSlide(filename=str(self.input_path)) def read_rect( @@ -1969,6 +2027,8 @@ def read_rect( interpolation=interpolation, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) def read_bounds( @@ -2154,6 +2214,8 @@ class docstrings for more information. interpolation=interpolation, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) @staticmethod @@ -2262,9 +2324,10 @@ def __init__( input_img: str | Path | np.ndarray, mpp: tuple[Number, Number] | None = None, power: Number | None = None, + post_proc: str | callable | None = "auto", ) -> None: """Initialize :class:`OmnyxJP2WSIReader`.""" - super().__init__(input_img=input_img, mpp=mpp, power=power) + super().__init__(input_img=input_img, mpp=mpp, power=power, post_proc=post_proc) import glymur glymur.set_option("lib.num_threads", os.cpu_count() or 1) @@ -2508,6 +2571,8 @@ def read_rect( interpolation=interpolation, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) def read_bounds( @@ -2682,6 +2747,8 @@ class docstrings for more information. interpolation=interpolation, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) @staticmethod @@ -2879,6 +2946,8 @@ class VirtualWSIReader(WSIReader): "bool" mode supports binary masks, interpolation in this case will be "nearest" instead of "bicubic". "feature" mode allows multichannel features. + post_proc (str, callable): + Post-processing function to apply to the output image. """ @@ -2889,12 +2958,14 @@ def __init__( power: Number | None = None, info: WSIMeta | None = None, mode: str = "rgb", + post_proc: str | callable | None = "auto", ) -> None: """Initialize :class:`VirtualWSIReader`.""" super().__init__( input_img=input_img, mpp=mpp, power=power, + post_proc=post_proc, ) if mode.lower() not in ["rgb", "bool", "feature"]: msg = "Invalid mode." @@ -3216,6 +3287,8 @@ def read_rect( ) if self.mode == "rgb": + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) return im_region @@ -3393,6 +3466,8 @@ class docstrings for more information. ) if self.mode == "rgb": + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) return im_region @@ -3456,12 +3531,13 @@ def __init__( mpp: tuple[Number, Number] | None = None, power: Number | None = None, series: str = "auto", - cache_size: int = 2**28, + cache_size: int = 2**28, # noqa: ARG002 + post_proc: str | callable | None = "auto", ) -> None: """Initialize :class:`TIFFWSIReader`.""" - super().__init__(input_img=input_img, mpp=mpp, power=power) + super().__init__(input_img=input_img, mpp=mpp, power=power, post_proc=post_proc) self.tiff = tifffile.TiffFile(self.input_path) - self._axes = self.tiff.pages[0].axes + self._axes = self.tiff.series[0].axes # Flag which is True if the image is a simple single page tile TIFF is_single_page_tiled = all( [ @@ -3472,7 +3548,14 @@ def __init__( len(self.tiff.pages) == 1, ], ) - if not any([self.tiff.is_svs, self.tiff.is_ome, is_single_page_tiled]): + if not any( + [ + self.tiff.is_svs, + self.tiff.is_ome, + is_single_page_tiled, + self.tiff.is_bigtiff, + ] + ): msg = "Unsupported TIFF WSI format." raise ValueError(msg) @@ -3486,7 +3569,7 @@ def __init__( def page_area(page: tifffile.TiffPage) -> float: """Calculate the area of a page.""" - return np.prod(self._canonical_shape(page.shape)[:2]) + return np.prod(self._canonical_shape(page.shape)[:2], dtype=float) series_areas = [page_area(s.pages[0]) for s in all_series] # skipcq self.series_n = np.argmax(series_areas) @@ -3496,8 +3579,8 @@ def page_area(page: tifffile.TiffPage) -> float: series=self.series_n, aszarr=True, ) - self._zarr_lru_cache = zarr.LRUStoreCache(self._zarr_store, max_size=cache_size) - self._zarr_group = zarr.open(self._zarr_lru_cache) + # remove LRU cache for now as seems to cause issues on windows + self._zarr_group = zarr.open(self._zarr_store) if not isinstance(self._zarr_group, zarr.hierarchy.Group): group = zarr.hierarchy.group() group[0] = self._zarr_group @@ -3516,12 +3599,12 @@ def _canonical_shape(self: TIFFWSIReader, shape: IntPair) -> tuple: Returns: tuple: - Shape in YXS order. + Shape in YXS or YXC order. """ - if self._axes == "YXS": + if self._axes in ("YXS", "YXC"): return shape - if self._axes == "SYX": + if self._axes in ("SYX", "CYX"): return np.roll(shape, -1) msg = f"Unsupported axes `{self._axes}`." raise ValueError(msg) @@ -4002,13 +4085,15 @@ def read_rect( pad_mode=pad_mode, pad_constant_values=pad_constant_values, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(im_region, alpha=False) # Find parameters for optimal read ( read_level, - _, - _, + level_read_location, + level_read_size, post_read_scale, baseline_read_size, ) = self.find_read_rect_params( @@ -4019,8 +4104,8 @@ def read_rect( ) bounds = utils.transforms.locsize2bounds( - location=location, - size=baseline_read_size, + location=level_read_location, + size=level_read_size, ) im_region = utils.image.safe_padded_read( image=self.level_arrays[read_level], @@ -4036,6 +4121,8 @@ def read_rect( interpolation=interpolation, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) def read_bounds( @@ -4163,7 +4250,7 @@ class docstrings for more information. # but base image is of different scale) ( read_level, - _, + bounds_at_read_level, _, post_read_scale, ) = self._find_read_bounds_params( @@ -4175,7 +4262,7 @@ class docstrings for more information. # Find parameters for optimal read ( read_level, - _, + bounds_at_read_level, size_at_requested, post_read_scale, ) = self._find_read_bounds_params( @@ -4186,7 +4273,7 @@ class docstrings for more information. im_region = utils.image.sub_pixel_read( image=self.level_arrays[read_level], - bounds=bounds_at_baseline, + bounds=bounds_at_read_level, output_size=size_at_requested, interpolation=interpolation, pad_mode=pad_mode, @@ -4208,6 +4295,8 @@ class docstrings for more information. output_size=size_at_requested, ) + if self.post_proc is not None: + return self.post_proc(im_region) return im_region @@ -4221,11 +4310,12 @@ def __init__( input_img: str | Path | np.ndarray, mpp: tuple[Number, Number] | None = None, power: Number | None = None, + post_proc: str | callable | None = "auto", ) -> None: """Initialize :class:`DICOMWSIReader`.""" from wsidicom import WsiDicom - super().__init__(input_img, mpp, power) + super().__init__(input_img, mpp, power, post_proc) self.wsi = WsiDicom.open(input_img) def _info(self: DICOMWSIReader) -> WSIMeta: @@ -4517,6 +4607,8 @@ def read_rect( interpolation=interpolation, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) def read_bounds( @@ -4712,6 +4804,8 @@ class docstrings for more information. interpolation=interpolation, ) + if self.post_proc is not None: + return self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) @@ -5035,6 +5129,8 @@ def read_rect( pad_mode=pad_mode, pad_constant_values=pad_constant_values, ) + if self.post_proc is not None: + return self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) # Find parameters for optimal read @@ -5069,6 +5165,8 @@ def read_rect( interpolation=interpolation, ) + if self.post_proc is not None: + im_region = self.post_proc(im_region) return utils.transforms.background_composite(image=im_region, alpha=False) def read_bounds( @@ -5606,6 +5704,8 @@ def read_rect( coord_space=coord_space, **kwargs, ) + if self.post_proc is not None: + base_region = self.post_proc(base_region) base_region = Image.fromarray( utils.transforms.background_composite(base_region, alpha=True), ) @@ -5799,6 +5899,8 @@ class docstrings for more information. coord_space=coord_space, **kwargs, ) + if self.post_proc is not None: + base_region = self.post_proc(base_region) base_region = Image.fromarray( utils.transforms.background_composite(base_region, alpha=True), ) From aec8390659279f0506b6b3c203d861efb768a360 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 21 Jun 2024 19:05:59 +0100 Subject: [PATCH 04/36] deepsource --- tiatoolbox/utils/postproc_defs.py | 3 ++- tiatoolbox/wsicore/wsireader.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index 91e5fc6ec..5b9ec505a 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -25,7 +25,8 @@ def __init__( if self.default_colors is not None: self.default_colors = np.array(self.default_colors, dtype=np.float32) - def generate_colors(self: MultichannelToRGB, n_channels: int) -> np.ndarray: + @staticmethod + def generate_colors(n_channels: int) -> np.ndarray: """Generate a set of visually distinct colors. Args: diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index f6532cd7d..b91afdbaf 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -4095,7 +4095,7 @@ def read_rect( level_read_location, level_read_size, post_read_scale, - baseline_read_size, + _, ) = self.find_read_rect_params( location=location, size=size, From 4972332f88f682ef3dd2a27e511c13337d024403 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Mon, 24 Jun 2024 15:52:48 +0100 Subject: [PATCH 05/36] make .tif be recognised same as .tiff --- tiatoolbox/visualization/bokeh_app/main.py | 1 + tiatoolbox/wsicore/wsireader.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index b58ae529e..4e6c04d3c 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -2099,6 +2099,7 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]: "*.svs", "*.ndpi", "*.tiff", + "*.tif", "*.mrxs", "*.png", "*.jpg", diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index b91afdbaf..3f6cc95dd 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -400,7 +400,9 @@ def open( # noqa: PLR0911 ) return NGFFWSIReader(input_path, mpp=mpp, power=power) - if suffixes[-2:] in ([".ome", ".tiff"],): + if suffixes[-2:] in ([".ome", ".tiff"],) or suffixes[-2:] in ( + [".ome", ".tif"], + ): return TIFFWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc) if last_suffix == ".qptiff": From dccf4db6ae81f88a886984111f6d5a450c88aaf7 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Wed, 26 Jun 2024 02:53:39 +0100 Subject: [PATCH 06/36] fix read_rect --- tiatoolbox/visualization/bokeh_app/main.py | 2 +- tiatoolbox/wsicore/wsireader.py | 40 +++++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index bdd612e3a..0bfa55fb4 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -2086,7 +2086,7 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]: # Set initial slide to first one in base folder slide_list = [] - for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.png", "*.jpg"]: + for ext in ["*.svs", "*ndpi", "*.tiff", "*.tif", "*.mrxs", "*.png", "*.jpg"]: slide_list.extend(list(doc_config["slide_folder"].glob(ext))) slide_list.extend( list(doc_config["slide_folder"].glob(str(Path("*") / ext))), diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index dc55ddc3c..82915eaad 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -2,7 +2,6 @@ from __future__ import annotations -import copy import json import logging import math @@ -380,7 +379,9 @@ def open( # noqa: PLR0911 ) return NGFFWSIReader(input_path, mpp=mpp, power=power) - if suffixes[-2:] in ([".ome", ".tiff"],): + if suffixes[-2:] in ([".ome", ".tiff"],) or suffixes[-2:] in ( + [".ome", ".tif"], + ): return TIFFWSIReader(input_path, mpp=mpp, power=power) if last_suffix in (".tif", ".tiff"): @@ -477,7 +478,7 @@ def info(self: WSIReader) -> WSIMeta: """ if self._m_info is not None: - return copy.deepcopy(self._m_info) + return self._m_info self._m_info = self._info() if self._manual_mpp: self._m_info.mpp = np.array(self._manual_mpp) @@ -3472,7 +3473,14 @@ def __init__( len(self.tiff.pages) == 1, ], ) - if not any([self.tiff.is_svs, self.tiff.is_ome, is_single_page_tiled]): + if not any( + [ + self.tiff.is_svs, + self.tiff.is_ome, + is_single_page_tiled, + self.tiff.is_bigtiff, + ] + ): msg = "Unsupported TIFF WSI format." raise ValueError(msg) @@ -3503,9 +3511,16 @@ def page_area(page: tifffile.TiffPage) -> float: group[0] = self._zarr_group self._zarr_group = group self.level_arrays = { - int(key): ArrayView(array, axes=self.info.axes) + int(key): ArrayView(array, axes=self._axes) for key, array in self._zarr_group.items() } + # ensure level arrays are sorted by descending area + self.level_arrays = dict( + sorted( + self.level_arrays.items(), + key=lambda x: -np.prod(self._canonical_shape(x[1].array.shape[:2])), + ) + ) def _canonical_shape(self: TIFFWSIReader, shape: IntPair) -> tuple: """Make a level shape tuple in YXS order. @@ -3761,10 +3776,10 @@ def _info(self: TIFFWSIReader) -> WSIMeta: Containing metadata. """ - level_count = len(self._zarr_group) + level_count = len(self.level_arrays) level_dimensions = [ - np.array(self._canonical_shape(p.shape)[:2][::-1]) - for p in self._zarr_group.values() + np.array(self._canonical_shape(p.array.shape)[:2][::-1]) + for p in self.level_arrays.values() ] slide_dimensions = level_dimensions[0] level_downsamples = [(level_dimensions[0] / x)[0] for x in level_dimensions] @@ -4007,8 +4022,8 @@ def read_rect( # Find parameters for optimal read ( read_level, - _, - _, + level_read_location, + level_read_size, post_read_scale, baseline_read_size, ) = self.find_read_rect_params( @@ -4019,8 +4034,8 @@ def read_rect( ) bounds = utils.transforms.locsize2bounds( - location=location, - size=baseline_read_size, + location=level_read_location, + size=level_read_size, ) im_region = utils.image.safe_padded_read( image=self.level_arrays[read_level], @@ -4028,7 +4043,6 @@ def read_rect( pad_mode=pad_mode, pad_constant_values=pad_constant_values, ) - im_region = utils.transforms.imresize( img=im_region, scale_factor=post_read_scale, From 79dcd51931aa86537cbba105ab3d27a4b8443b4a Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Wed, 26 Jun 2024 22:20:05 +0100 Subject: [PATCH 07/36] fix typo --- tests/test_wsireader.py | 1 + tiatoolbox/utils/postproc_defs.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index c6bb6e3d9..e8d0f28da 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -2663,6 +2663,7 @@ def test_read_rect_level_consistency(wsi: WSIReader) -> None: # adds some tolerance for the comparison. blurred = [cv2.GaussianBlur(img, (5, 5), cv2.BORDER_REFLECT) for img in resized] as_float = [img.astype(np.float64) for img in blurred] + # Pair-wise check resolutions for mean squared error for i, a in enumerate(as_float): for b in as_float[i + 1 :]: diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index ee56f5dd9..28170a132 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -23,7 +23,7 @@ def __init__( """ self.colors = colors if self.colors is not None: - self.colors = np.array(self.default_colors, dtype=np.float32) + self.colors = np.array(self.colors, dtype=np.float32) @staticmethod def generate_colors(n_channels: int) -> np.ndarray: From a80655b4200f30d1a58a6666777ad49f176e4dfd Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Tue, 2 Jul 2024 01:30:17 +0100 Subject: [PATCH 08/36] get colors from metadata if poss. --- tiatoolbox/utils/postproc_defs.py | 26 +++++++++++++++----------- tiatoolbox/wsicore/wsireader.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index 28170a132..427824574 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -12,21 +12,18 @@ class MultichannelToRGB: def __init__( self: MultichannelToRGB, - colors: list[tuple[float, float, float]] | None = None, + color_dict: list[tuple[float, float, float]] | None = None, ) -> None: """Initialize the MultichannelToRGB converter. Args: - colors: List of RGB colors for each channel. If not + color_dict: Dict of channel names with RGB colors for each channel. If not provided, a set of distinct colors will be auto-generated. """ - self.colors = colors - if self.colors is not None: - self.colors = np.array(self.colors, dtype=np.float32) + self.color_dict = color_dict - @staticmethod - def generate_colors(n_channels: int) -> np.ndarray: + def generate_colors(self: MultichannelToRGB, n_channels: int) -> np.ndarray: """Generate a set of visually distinct colors. Args: @@ -36,10 +33,10 @@ def generate_colors(n_channels: int) -> np.ndarray: np.ndarray: Array of RGB colors """ - return np.array( - [colorsys.hsv_to_rgb(i / n_channels, 1, 1) for i in range(n_channels)], - dtype=np.float32, - ) + self.color_dict = { + f"channel_{i}": colorsys.hsv_to_rgb(i / n_channels, 1, 1) + for i in range(n_channels) + } def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: """Convert a multi-channel image to an RGB image. @@ -65,3 +62,10 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: # Clip to ensure in valid range and return return np.clip(rgb_image, 0, 255).astype(np.uint8) + + def __setattr__(self: MultichannelToRGB, name: str, value: np.Any) -> None: + """Ensure that colors is updated if color_dict is updated.""" + if name == "color_dict" and value is not None: + self.colors = np.array(list(value.values()), dtype=np.float32) + + super().__setattr__(name, value) diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index 26a914e70..810a5b320 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING +import matplotlib.colors as mcolors import numpy as np import openslide import pandas as pd @@ -3597,6 +3598,36 @@ def page_area(page: tifffile.TiffPage) -> float: key=lambda x: -np.prod(self._canonical_shape(x[1].array.shape[:2])), ) ) + # maybe get colors if they exist in metadata + self._get_colors_from_meta() + + def _get_colors_from_meta(self: TIFFWSIReader) -> None: + """Get colors from metadata if they exist.""" + if isinstance(self.post_proc, postproc_defs.MultichannelToRGB): + xml = self.info.raw["Description"] + try: + root = ElementTree.fromstring(xml) + except ElementTree.ParseError: + return + color_info = root.find(".//ScanColorTable") + if color_info is not None: + color_dict = { + k.text.split("_")[0]: v.text + for k, v in zip( + color_info.iterfind("ScanColorTable-k"), + color_info.iterfind("ScanColorTable-v"), + ) + } + # values will be either a string of 3 ints e.g 155, 128, 0, or + # a color name e.g Lime. Convert them all to RGB tuples. + for key, value in color_dict.items(): + if value is None: + continue + if "," in value: + color_dict[key] = tuple(int(x) / 255 for x in value.split(",")) + else: + color_dict[key] = mcolors.to_rgb(value) + self.post_proc.color_dict = color_dict def _canonical_shape(self: TIFFWSIReader, shape: IntPair) -> tuple: """Make a level shape tuple in YXS order. From 83c695469fbd9a61d381a3ab6ed82473ccf3c1fe Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Tue, 2 Jul 2024 16:47:50 +0100 Subject: [PATCH 09/36] deepsource --- tiatoolbox/utils/postproc_defs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index 427824574..ee7dd413d 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -21,6 +21,7 @@ def __init__( provided, a set of distinct colors will be auto-generated. """ + self.colors = None self.color_dict = color_dict def generate_colors(self: MultichannelToRGB, n_channels: int) -> np.ndarray: @@ -55,7 +56,7 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: return image if self.colors is None: - self.colors = self.generate_colors(n) + self.generate_colors(n) # Convert to RGB image rgb_image = np.einsum("hwn,nc->hwc", image, self.colors, optimize=True) From 6e58ec51c13ea0ae0549b635144d5a1ca08a584d Mon Sep 17 00:00:00 2001 From: behnazelhaminia Date: Fri, 5 Jul 2024 10:12:54 +0100 Subject: [PATCH 10/36] :white_check_mark: Adding test for mutli_channel visualisation --- tests/test_wsireader.py | 17 +++++++++++++++-- tiatoolbox/utils/postproc_defs.py | 4 +++- tiatoolbox/wsicore/wsireader.py | 4 ++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index e8d0f28da..9d472502e 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -211,7 +211,7 @@ def read_bounds_level_consistency(wsi: WSIReader, bounds: IntBounds) -> None: # Pair-wise check resolutions for mean squared error for i, a in enumerate(as_float): - for b in as_float[i + 1 :]: + for b in as_float[i + 1:]: _, error, phase_diff = phase_cross_correlation(a, b, normalization=None) assert phase_diff < 0.125 assert error < 0.125 @@ -2666,7 +2666,7 @@ def test_read_rect_level_consistency(wsi: WSIReader) -> None: # Pair-wise check resolutions for mean squared error for i, a in enumerate(as_float): - for b in as_float[i + 1 :]: + for b in as_float[i + 1:]: _, error, phase_diff = phase_cross_correlation(a, b, normalization=None) assert phase_diff < 0.125 assert error < 0.125 @@ -2804,3 +2804,16 @@ def test_read_multi_channel(source_image: Path) -> None: assert region.shape == (100, 50, (new_img_array.shape[-1])) assert np.abs(np.median(region.astype(int) - target.astype(int))) == 0 assert np.abs(np.mean(region.astype(int) - target.astype(int))) < 0.2 + + +def test_visualise_multi_channel(sample_qptiff: Path) -> None: + """Test visualising a multi-channel qptiff image""" + + wsi = wsireader.TIFFWSIReader(sample_qptiff, post_proc='auto') + wsi2 = wsireader.TIFFWSIReader(sample_qptiff, post_proc=None) + + region = wsi.read_rect(location=(0, 0), size=(50, 100)) + region2 = wsi2.read_rect(location=(0, 0), size=(50, 100)) + + assert region.shape == (100, 50, 3) + assert region2.shape == (100, 50, 7) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index ee7dd413d..b7e3aa5c9 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -50,6 +50,8 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: """ n = image.shape[2] + print(n) + print(self.color_dict) if n < 5: # noqa: PLR2004 # assume already rgb(a) so just return image @@ -59,7 +61,7 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: self.generate_colors(n) # Convert to RGB image - rgb_image = np.einsum("hwn,nc->hwc", image, self.colors, optimize=True) + rgb_image = np.einsum("hwn,nc->hwc", image, self.colors[:, :], optimize=True) # Clip to ensure in valid range and return return np.clip(rgb_image, 0, 255).astype(np.uint8) diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index 810a5b320..fb858dae3 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -4126,7 +4126,7 @@ def read_rect( ) if self.post_proc is not None: im_region = self.post_proc(im_region) - return utils.transforms.background_composite(im_region, alpha=False) + return im_region # Find parameters for optimal read ( @@ -4161,7 +4161,7 @@ def read_rect( if self.post_proc is not None: im_region = self.post_proc(im_region) - return utils.transforms.background_composite(image=im_region, alpha=False) + return im_region def read_bounds( self: TIFFWSIReader, From c4e4232ac817a48e4c227539a7b684a36fea4833 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 09:16:16 +0000 Subject: [PATCH 11/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_wsireader.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 9d472502e..cc9d3157b 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -211,7 +211,7 @@ def read_bounds_level_consistency(wsi: WSIReader, bounds: IntBounds) -> None: # Pair-wise check resolutions for mean squared error for i, a in enumerate(as_float): - for b in as_float[i + 1:]: + for b in as_float[i + 1 :]: _, error, phase_diff = phase_cross_correlation(a, b, normalization=None) assert phase_diff < 0.125 assert error < 0.125 @@ -2666,7 +2666,7 @@ def test_read_rect_level_consistency(wsi: WSIReader) -> None: # Pair-wise check resolutions for mean squared error for i, a in enumerate(as_float): - for b in as_float[i + 1:]: + for b in as_float[i + 1 :]: _, error, phase_diff = phase_cross_correlation(a, b, normalization=None) assert phase_diff < 0.125 assert error < 0.125 @@ -2808,8 +2808,7 @@ def test_read_multi_channel(source_image: Path) -> None: def test_visualise_multi_channel(sample_qptiff: Path) -> None: """Test visualising a multi-channel qptiff image""" - - wsi = wsireader.TIFFWSIReader(sample_qptiff, post_proc='auto') + wsi = wsireader.TIFFWSIReader(sample_qptiff, post_proc="auto") wsi2 = wsireader.TIFFWSIReader(sample_qptiff, post_proc=None) region = wsi.read_rect(location=(0, 0), size=(50, 100)) From 5563e36d7e6e2ac5cd633fbcbc411271fd0f57c4 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:06:57 +0100 Subject: [PATCH 12/36] replace depreciated funcTickFormatter --- tiatoolbox/visualization/bokeh_app/main.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 4e6c04d3c..38c581987 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -14,6 +14,10 @@ import numpy as np import requests import torch +from matplotlib import colormaps +from PIL import Image +from requests.adapters import HTTPAdapter, Retry + from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from bokeh.io import curdoc from bokeh.layouts import column, row @@ -28,10 +32,10 @@ Column, ColumnDataSource, CustomJS, + CustomJSTickFormatter, DataTable, Div, Dropdown, - FuncTickFormatter, Glyph, HoverTool, HTMLTemplateFormatter, @@ -58,9 +62,6 @@ from bokeh.models.tiles import WMTSTileSource from bokeh.plotting import figure from bokeh.util import token -from matplotlib import colormaps -from PIL import Image -from requests.adapters import HTTPAdapter, Retry # GitHub actions seems unable to find TIAToolbox unless this is here sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) @@ -630,7 +631,7 @@ def __init__(self: ViewerState, slide_path: str | Path) -> None: self.thickness = -1 self.model_mpp = 0 self.init = True - self.micron_formatter = FuncTickFormatter( + self.micron_formatter = CustomJSTickFormatter( args={"mpp": 0.1}, code=""" return Math.round(tick*mpp) From cc66c5305d399a6b677d79b6b38b22336a7751a3 Mon Sep 17 00:00:00 2001 From: behnazelhaminia Date: Fri, 5 Jul 2024 12:46:55 +0100 Subject: [PATCH 13/36] :white_check_mark: Adding test for mutli_channel visualisation --- tests/test_wsireader.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 9d472502e..35f1b8935 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -211,7 +211,7 @@ def read_bounds_level_consistency(wsi: WSIReader, bounds: IntBounds) -> None: # Pair-wise check resolutions for mean squared error for i, a in enumerate(as_float): - for b in as_float[i + 1:]: + for b in as_float[i + 1 :]: _, error, phase_diff = phase_cross_correlation(a, b, normalization=None) assert phase_diff < 0.125 assert error < 0.125 @@ -2666,7 +2666,7 @@ def test_read_rect_level_consistency(wsi: WSIReader) -> None: # Pair-wise check resolutions for mean squared error for i, a in enumerate(as_float): - for b in as_float[i + 1:]: + for b in as_float[i + 1 :]: _, error, phase_diff = phase_cross_correlation(a, b, normalization=None) assert phase_diff < 0.125 assert error < 0.125 @@ -2807,9 +2807,8 @@ def test_read_multi_channel(source_image: Path) -> None: def test_visualise_multi_channel(sample_qptiff: Path) -> None: - """Test visualising a multi-channel qptiff image""" - - wsi = wsireader.TIFFWSIReader(sample_qptiff, post_proc='auto') + """Test visualising a multi-channel qptiff image.""" + wsi = wsireader.TIFFWSIReader(sample_qptiff, post_proc="auto") wsi2 = wsireader.TIFFWSIReader(sample_qptiff, post_proc=None) region = wsi.read_rect(location=(0, 0), size=(50, 100)) From 7f4423c58ce99c1fc16b27e6fc54f2f4acf2781c Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:11:36 +0100 Subject: [PATCH 14/36] drop background autofluorescence channel if appropriate --- tiatoolbox/utils/postproc_defs.py | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index ee7dd413d..e41e3517c 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -3,6 +3,7 @@ from __future__ import annotations import colorsys +import warnings import numpy as np @@ -23,6 +24,39 @@ def __init__( """ self.colors = None self.color_dict = color_dict + self.is_validated = False + + def validate(self: MultichannelToRGB, n: int) -> None: + """Validate the input color_dict on first read from image. + + Checks that n is either equal to the number of colors provided, or is + one less. In the latter case it is assumed that the last channel is background + autofluorescence and is not in the tiff and we will drop it from + the color_dict with a warning. + + Args: + n (int): Number of channels + + """ + n_colors = len(self.color_dict) + if n_colors == n: + self.is_validated = True + return + + if n_colors - 1 == n: + self.color_dict.pop(list(self.color_dict.keys())[-1]) + self.colors = self.colors[:-1] + self.is_validated = True + warnings.warn( + """Number of channels in image is one less than number of channels in + dict. Assuming last channel is background autofluorescence and dropping + it. If this is not the case please provide a manual color_dict.""", + stacklevel=2, + ) + return + + msg = f"Number of colors: {n_colors} does not match channels in image: {n}." + raise ValueError(msg) def generate_colors(self: MultichannelToRGB, n_channels: int) -> np.ndarray: """Generate a set of visually distinct colors. @@ -58,6 +92,9 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: if self.colors is None: self.generate_colors(n) + if not self.is_validated: + self.validate(n) + # Convert to RGB image rgb_image = np.einsum("hwn,nc->hwc", image, self.colors, optimize=True) From 8e2c11ae871e40bd46f63e589b23983a7f57bd7e Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:01:24 +0100 Subject: [PATCH 15/36] remove a print --- tiatoolbox/utils/postproc_defs.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index c35452a81..e4ee3174c 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -84,8 +84,6 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: """ n = image.shape[2] - print(n) - print(self.color_dict) if n < 5: # noqa: PLR2004 # assume already rgb(a) so just return image From fad00c334744b8a3ee3ae0a7a1925ed96a115d4f Mon Sep 17 00:00:00 2001 From: Behnaz Date: Fri, 9 Aug 2024 14:46:33 +0100 Subject: [PATCH 16/36] :white_check_mark: Assert shape and interpolation --- tests/test_wsireader.py | 22 ++++++++++++++++++++++ tiatoolbox/data/remote_samples.yaml | 2 ++ 2 files changed, 24 insertions(+) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index e8d0f28da..108038e26 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -1474,6 +1474,7 @@ def test_wsireader_open( sample_ome_tiff: Path, sample_ventana_tif: Path, sample_regular_tif: Path, + sample_qptiff: Path, source_image: Path, tmp_path: pytest.TempPathFactory, ) -> None: @@ -1505,6 +1506,9 @@ def test_wsireader_open( wsi = WSIReader.open(Path(source_image)) assert isinstance(wsi, wsireader.VirtualWSIReader) + wsi = WSIReader.open(sample_qptiff) + assert isinstance(wsi, wsireader.TIFFWSIReader) + img = utils.misc.imread(str(Path(source_image))) wsi = WSIReader.open(input_img=img) assert isinstance(wsi, wsireader.VirtualWSIReader) @@ -2573,6 +2577,11 @@ def test_jp2_no_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: "sample_key": "jp2-omnyx-small", "kwargs": {}, }, + { + "reader_class": TIFFWSIReader, + "sample_key": "qptiff_sample", + "kwargs": {}, + }, ], ids=[ "AnnotationReaderOverlaid", @@ -2804,3 +2813,16 @@ def test_read_multi_channel(source_image: Path) -> None: assert region.shape == (100, 50, (new_img_array.shape[-1])) assert np.abs(np.median(region.astype(int) - target.astype(int))) == 0 assert np.abs(np.mean(region.astype(int) - target.astype(int))) < 0.2 + + +def test_visualise_multi_channel(sample_qptiff: Path) -> None: + """Test visualising a multi-channel qptiff image.""" + wsi = wsireader.TIFFWSIReader(sample_qptiff, post_proc="auto") + wsi2 = wsireader.TIFFWSIReader(sample_qptiff, post_proc=None) + + region = wsi.read_rect(location=(0, 0), size=(50, 100)) + region2 = wsi2.read_rect(location=(0, 0), size=(50, 100)) + + assert region.shape == (100, 50, 3) + assert region2.shape == (100, 50, 7) + diff --git a/tiatoolbox/data/remote_samples.yaml b/tiatoolbox/data/remote_samples.yaml index 58fff49d3..75e5fccf7 100644 --- a/tiatoolbox/data/remote_samples.yaml +++ b/tiatoolbox/data/remote_samples.yaml @@ -143,3 +143,5 @@ files: url: [ *testdata, "annotation/test2_config.json"] nuclick-output: url: [*modelroot, "predictions/nuclei_mask/nuclick-output.npy"] + qptiff_sample: + url: [*wsis, "multiplexed_example.qptiff"] From 464a5a2116684f5a5be55989f3bbb7cb717fced0 Mon Sep 17 00:00:00 2001 From: Behnaz Date: Fri, 9 Aug 2024 14:51:07 +0100 Subject: [PATCH 17/36] :white_check_mark: Test for multichannel visualisation --- tests/test_wsireader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 108038e26..989d0c7d8 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -2825,4 +2825,3 @@ def test_visualise_multi_channel(sample_qptiff: Path) -> None: assert region.shape == (100, 50, 3) assert region2.shape == (100, 50, 7) - From 75bbdbdc0a7beb1153581981f03ef8f80344ac18 Mon Sep 17 00:00:00 2001 From: Behnaz Date: Fri, 9 Aug 2024 15:04:24 +0100 Subject: [PATCH 18/36] :white_check_mark: Test for multichannel visualisation --- tests/test_wsireader.py | 2 +- tiatoolbox/data/remote_samples.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 802f28543..137f6c1c6 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -2824,7 +2824,7 @@ def test_read_multi_channel(source_image: Path) -> None: def test_visualise_multi_channel(sample_qptiff: Path) -> None: - """Test visualising a multi-channel qptiff image.""" + """Test visualising a multi-channel qptiff multiplex image.""" wsi = wsireader.TIFFWSIReader(sample_qptiff, post_proc="auto") wsi2 = wsireader.TIFFWSIReader(sample_qptiff, post_proc=None) diff --git a/tiatoolbox/data/remote_samples.yaml b/tiatoolbox/data/remote_samples.yaml index 5182c87a4..aa23b8353 100644 --- a/tiatoolbox/data/remote_samples.yaml +++ b/tiatoolbox/data/remote_samples.yaml @@ -146,4 +146,4 @@ files: nuclick-output: url: [*modelroot, "predictions/nuclei_mask/nuclick-output.npy"] qptiff_sample: - url: [*wsis, "multiplexed_example.qptiff"] + url: [*wsis, "multiplexed_example.qptif"] From b148ed38e4d010d225edc68314cea8472a6f393a Mon Sep 17 00:00:00 2001 From: behnazelhaminia <30952176+behnazelhaminia@users.noreply.github.com> Date: Fri, 9 Aug 2024 15:05:41 +0100 Subject: [PATCH 19/36] Update remote_samples.yaml --- tiatoolbox/data/remote_samples.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiatoolbox/data/remote_samples.yaml b/tiatoolbox/data/remote_samples.yaml index aa23b8353..5182c87a4 100644 --- a/tiatoolbox/data/remote_samples.yaml +++ b/tiatoolbox/data/remote_samples.yaml @@ -146,4 +146,4 @@ files: nuclick-output: url: [*modelroot, "predictions/nuclei_mask/nuclick-output.npy"] qptiff_sample: - url: [*wsis, "multiplexed_example.qptif"] + url: [*wsis, "multiplexed_example.qptiff"] From 93ed2d1e386b068c34029c46f94204c2ca68c5e2 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Wed, 21 Aug 2024 20:17:00 +0100 Subject: [PATCH 20/36] first channel selector draft --- tiatoolbox/visualization/bokeh_app/main.py | 130 +++++++++++++++++++++ tiatoolbox/visualization/tileserver.py | 14 +++ 2 files changed, 144 insertions(+) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 7439262d8..ea911ebe8 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -22,6 +22,7 @@ BoxEditTool, Button, CheckboxButtonGroup, + CheckboxEditor, Circle, ColorBar, ColorPicker, @@ -46,6 +47,7 @@ Select, Slider, Spinner, + StringEditor, TableColumn, TabPanel, Tabs, @@ -137,6 +139,131 @@ def format_info(info: dict[str, Any]) -> str: return info_str +def get_color_dictionary() -> dict[str, tuple[int, int, int]]: + """Get the colors for the channels.""" + resp = UI["s"].get(f"http://{host2}:5000/tileserver/channels") + try: + return json.loads(resp.text) + except json.JSONDecodeError: + return {} + + +def set_color_dictionary(colors: dict[str, tuple[int, int, int]]) -> None: + """Set the colors for the channels.""" + UI["s"].put(f"http://{host2}:5000/tileserver/channels", data=json.dumps(colors)) + + +def set_active_channels(active_channels: list[str]) -> None: + """Set the active channels in the image.""" + UI["s"].put( + f"http://{host2}:5000/tileserver/active_channels", + data=json.dumps(active_channels), + ) + + +def get_active_channels() -> list[str]: + """Get the active channels in the image.""" + return [f"channel{i}" for i in range(1, 11)] + + +def create_channel_color_ui() -> Column: + """Create the channel select/color management UI.""" + # Start with an empty ColumnDataSource + source = ColumnDataSource(data={"channels": [], "colors": [], "active": []}) + + # Custom formatter for the color column + color_formatter = HTMLTemplateFormatter( + template="""
<%= value %>
""" + ) + + columns = [ + TableColumn( + field="channels", title="Channel", editor=StringEditor(), sortable=False + ), + TableColumn( + field="colors", + title="Color", + editor=StringEditor(), + formatter=color_formatter, + sortable=False, + ), + TableColumn( + field="active", title="Active", editor=CheckboxEditor(), sortable=False + ), + ] + data_table = DataTable( + source=source, columns=columns, editable=True, width=400, height=400 + ) + + color_picker = ColorPicker(title="Selected Channel Color", width=100) + + def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Channel color picker callback.""" + selected = source.selected.indices + if selected: + source.patch({"colors": [(selected[0], new)]}) + + color_picker.on_change("color", update_selected_color) + + apply_button = Button(label="Apply Changes", button_type="success") + + def apply_changes() -> None: + """Apply the changes to the image.""" + data = source.data + colors = dict(zip(data["channels"], data["colors"])) + active_channels = [ + channel + for channel, is_active in zip(data["channels"], data["active"]) + if is_active + ] + + set_color_dictionary(colors) + set_active_channels(active_channels) + + apply_button.on_click(apply_changes) + + def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Update the color picker when a new row is selected.""" + if new: + selected_color = source.data["colors"][new[0]] + color_picker.color = selected_color + else: + color_picker.color = None + + source.selected.on_change("indices", update_color_picker) + + instructions = Div( + text=""" +

Instructions:

+
    +
  • Use the table to view and edit channel properties
  • +
  • Click on a row to select a channel
  • +
  • Use the color picker to change the color of the selected channel
  • +
  • Check or uncheck the 'Active' column to toggle channel visibility
  • +
  • Click 'Apply Changes' to update the image
  • +
+ """ + ) + + return column(instructions, row(data_table, column(color_picker, apply_button))) + + +def populate_table() -> None: + """Populate the channel color table.""" + # Access the ColumnDataSource from the UI dictionary + source = UI["channel_select"].children[1].children[0].source + colors = get_color_dictionary() + active_channels = get_active_channels() + + new_data = { + "channels": list(colors.keys()), + "colors": [rgb2hex(color) for color in colors.values()], + "active": [channel in active_channels for channel in colors], + } + source.data = new_data + + def get_view_bounds( dims: tuple[float, float], plot_size: tuple[float, float], @@ -876,6 +1003,7 @@ def slide_select_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 initialise_slide() fname = make_safe_name(str(slide_path)) UI["s"].put(f"http://{host2}:5000/tileserver/slide", data={"slide_path": fname}) + populate_table() change_tiles("slide") # Load the overlay and graph automatically if set in config @@ -1652,12 +1780,14 @@ def gather_ui_elements( # noqa: PLR0915 "pt_size_spinner", "edge_size_spinner", "res_switch", + "channel_select", ], [ opt_buttons, pt_size_spinner, edge_size_spinner, res_switch, + create_channel_color_ui(), ], ), ) diff --git a/tiatoolbox/visualization/tileserver.py b/tiatoolbox/visualization/tileserver.py index b0a427502..053d6e6f8 100644 --- a/tiatoolbox/visualization/tileserver.py +++ b/tiatoolbox/visualization/tileserver.py @@ -164,6 +164,8 @@ def __init__( # noqa: PLR0915 ) self.route("/tileserver/tap_query//")(self.tap_query) self.route("/tileserver/prop_range", methods=["PUT"])(self.prop_range) + self.route("/tileserver/channels", methods=["GET"])(self.get_channels) + self.route("/tileserver/channels", methods=["PUT"])(self.set_channels) self.route("/tileserver/shutdown", methods=["POST"])(self.shutdown) def _get_session_id(self: TileServer) -> str: @@ -718,6 +720,18 @@ def prop_range(self: TileServer) -> str: self.renderers[session_id].score_fn = lambda x: (x - minv) / (maxv - minv) return "done" + def get_channels(self: TileServer) -> Response: + """Get the channels of the slide.""" + session_id = self._get_session_id() + return jsonify(self.layers[session_id]["slide"].post_proc.color_dict) + + def set_channels(self: TileServer) -> str: + """Set the channels of the slide.""" + session_id = self._get_session_id() + channels = json.loads(request.form["channels"]) + self.layers[session_id]["slide"].post_proc.color_dict = channels + return "done" + @staticmethod def shutdown() -> None: """Shutdown the tileserver.""" From df5a59d1a612c3788ba7aaa7bae1c0c0fcdac364 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:50:53 +0100 Subject: [PATCH 21/36] multichannel ui update --- tests/test_app_bokeh.py | 9 +-- tests/test_wsireader.py | 1 + tiatoolbox/cli/visualize.py | 1 + tiatoolbox/utils/postproc_defs.py | 22 +++++-- tiatoolbox/visualization/bokeh_app/main.py | 77 +++++++++++++--------- tiatoolbox/visualization/tileserver.py | 27 +++++++- 6 files changed, 94 insertions(+), 43 deletions(-) diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py index cb99a3df3..e8eec47ec 100644 --- a/tests/test_app_bokeh.py +++ b/tests/test_app_bokeh.py @@ -11,19 +11,19 @@ from pathlib import Path from typing import TYPE_CHECKING -import bokeh.models as bkmodels import matplotlib.pyplot as plt import numpy as np import pytest import requests -from bokeh.application import Application -from bokeh.application.handlers import FunctionHandler -from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from flask_cors import CORS from matplotlib import colormaps from PIL import Image from scipy.ndimage import label +import bokeh.models as bkmodels +from bokeh.application import Application +from bokeh.application.handlers import FunctionHandler +from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from tiatoolbox.data import _fetch_remote_sample from tiatoolbox.visualization.bokeh_app import main from tiatoolbox.visualization.tileserver import TileServer @@ -138,6 +138,7 @@ def run_app() -> None: title="Tiatoolbox TileServer", layers={}, ) + app.json.sort_keys = False CORS(app, send_wildcard=True) app.run(host="127.0.0.1", threaded=True) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 137f6c1c6..63e404932 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -2600,6 +2600,7 @@ def test_jp2_no_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: "NGFFWSIReader", "OpenSlideWSIReader (Small SVS)", "OmnyxJP2WSIReader", + "TIFFReader_Multichannel", ], ) def wsi(request: requests.request, remote_sample: Callable) -> WSIReader: diff --git a/tiatoolbox/cli/visualize.py b/tiatoolbox/cli/visualize.py index f1edbb318..9c1bf7774 100644 --- a/tiatoolbox/cli/visualize.py +++ b/tiatoolbox/cli/visualize.py @@ -25,6 +25,7 @@ def run_app() -> None: title="Tiatoolbox TileServer", layers={}, ) + app.json.sort_keys = False CORS(app, send_wildcard=True) app.run(host="127.0.0.1", threaded=True) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index e4ee3174c..a0954384c 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -25,6 +25,8 @@ def __init__( self.colors = None self.color_dict = color_dict self.is_validated = False + self.channels = None + self.enhance = 1.0 def validate(self: MultichannelToRGB, n: int) -> None: """Validate the input color_dict on first read from image. @@ -38,18 +40,18 @@ def validate(self: MultichannelToRGB, n: int) -> None: n (int): Number of channels """ - n_colors = len(self.color_dict) + n_colors = len(self.colors) if n_colors == n: self.is_validated = True return if n_colors - 1 == n: - self.color_dict.pop(list(self.color_dict.keys())[-1]) - self.colors = self.colors[:-1] + self.colors = self.colors[:n] + self.channels = [c for c in self.channels if c < n] self.is_validated = True warnings.warn( """Number of channels in image is one less than number of channels in - dict. Assuming last channel is background autofluorescence and dropping + dict. Assuming last channel is background autofluorescence and ignoring it. If this is not the case please provide a manual color_dict.""", stacklevel=2, ) @@ -96,7 +98,15 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: self.validate(n) # Convert to RGB image - rgb_image = np.einsum("hwn,nc->hwc", image, self.colors[:, :], optimize=True) + rgb_image = ( + np.einsum( + "hwn,nc->hwc", + image[:, :, self.channels], + self.colors[self.channels, :], + optimize=True, + ) + * self.enhance + ) # Clip to ensure in valid range and return return np.clip(rgb_image, 0, 255).astype(np.uint8) @@ -105,5 +115,7 @@ def __setattr__(self: MultichannelToRGB, name: str, value: np.Any) -> None: """Ensure that colors is updated if color_dict is updated.""" if name == "color_dict" and value is not None: self.colors = np.array(list(value.values()), dtype=np.float32) + if self.channels is None: + self.channels = list(range(len(value))) super().__setattr__(name, value) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 65b12a841..f679bbaf1 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -140,33 +140,26 @@ def format_info(info: dict[str, Any]) -> str: return info_str -def get_color_dictionary() -> dict[str, tuple[int, int, int]]: +def get_channel_info() -> dict[str, tuple[int, int, int]]: """Get the colors for the channels.""" resp = UI["s"].get(f"http://{host2}:5000/tileserver/channels") try: - return json.loads(resp.text) + resp = json.loads(resp.text) + return resp["channels"], resp["active"] except json.JSONDecodeError: - return {} + return {}, [] -def set_color_dictionary(colors: dict[str, tuple[int, int, int]]) -> None: +def set_channel_info( + colors: dict[str, tuple[int, int, int]], active_channels: list +) -> None: """Set the colors for the channels.""" - UI["s"].put(f"http://{host2}:5000/tileserver/channels", data=json.dumps(colors)) - - -def set_active_channels(active_channels: list[str]) -> None: - """Set the active channels in the image.""" UI["s"].put( - f"http://{host2}:5000/tileserver/active_channels", - data=json.dumps(active_channels), + f"http://{host2}:5000/tileserver/channels", + data={"channels": json.dumps(colors), "active": json.dumps(active_channels)}, ) -def get_active_channels() -> list[str]: - """Get the active channels in the image.""" - return [f"channel{i}" for i in range(1, 11)] - - def create_channel_color_ui() -> Column: """Create the channel select/color management UI.""" # Start with an empty ColumnDataSource @@ -194,12 +187,12 @@ def create_channel_color_ui() -> Column: ), ] data_table = DataTable( - source=source, columns=columns, editable=True, width=400, height=400 + source=source, columns=columns, editable=True, width=250, height=200 ) - color_picker = ColorPicker(title="Selected Channel Color", width=100) + color_picker = ColorPicker(title="Selected Color", width=100) - def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG001 + def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 """Channel color picker callback.""" selected = source.selected.indices if selected: @@ -207,24 +200,24 @@ def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG00 color_picker.on_change("color", update_selected_color) - apply_button = Button(label="Apply Changes", button_type="success") + apply_button = Button( + label="Apply Changes", button_type="success", margin=(20, 5, 5, 5) + ) def apply_changes() -> None: """Apply the changes to the image.""" data = source.data colors = dict(zip(data["channels"], data["colors"])) active_channels = [ - channel - for channel, is_active in zip(data["channels"], data["active"]) - if is_active + channel for channel, is_active in enumerate(data["active"]) if is_active ] - set_color_dictionary(colors) - set_active_channels(active_channels) + set_channel_info({ch: hex2rgb(colors[ch]) for ch in colors}, active_channels) + change_tiles("slide") apply_button.on_click(apply_changes) - def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 + def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 """Update the color picker when a new row is selected.""" if new: selected_color = source.data["colors"][new[0]] @@ -234,6 +227,26 @@ def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 source.selected.on_change("indices", update_color_picker) + enhance_slider = Slider( + start=0.1, + end=10, + value=1, + step=0.1, + title="Enhance", + width=200, + ) + + def enhance_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 + """Enhance slider callback.""" + UI["s"].put( + f"http://{host2}:5000/tileserver/enhance", + data={"val": json.dumps(new)}, + ) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["slide"]) + + enhance_slider.on_change("value", enhance_cb) + instructions = Div( text="""

Instructions:

@@ -247,20 +260,22 @@ def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 """ ) - return column(instructions, row(data_table, column(color_picker, apply_button))) + return column( + instructions, + column(data_table, row(color_picker, apply_button), enhance_slider), + ) def populate_table() -> None: """Populate the channel color table.""" # Access the ColumnDataSource from the UI dictionary source = UI["channel_select"].children[1].children[0].source - colors = get_color_dictionary() - active_channels = get_active_channels() + colors, active_channels = get_channel_info() new_data = { "channels": list(colors.keys()), "colors": [rgb2hex(color) for color in colors.values()], - "active": [channel in active_channels for channel in colors], + "active": [channel in active_channels for channel in range(len(colors))], } source.data = new_data @@ -1004,8 +1019,8 @@ def slide_select_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 initialise_slide() fname = make_safe_name(str(slide_path)) UI["s"].put(f"http://{host2}:5000/tileserver/slide", data={"slide_path": fname}) - populate_table() change_tiles("slide") + populate_table() # Load the overlay and graph automatically if set in config if doc_config["auto_load"]: diff --git a/tiatoolbox/visualization/tileserver.py b/tiatoolbox/visualization/tileserver.py index 053d6e6f8..5b637e169 100644 --- a/tiatoolbox/visualization/tileserver.py +++ b/tiatoolbox/visualization/tileserver.py @@ -24,6 +24,7 @@ from tiatoolbox.annotation import AnnotationStore, SQLiteStore from tiatoolbox.tools.pyramid import AnnotationTileGenerator, ZoomifyGenerator from tiatoolbox.utils.misc import add_from_dat, store_from_dat +from tiatoolbox.utils.postproc_defs import MultichannelToRGB from tiatoolbox.utils.visualization import AnnotationRenderer, colourise_image from tiatoolbox.wsicore.wsireader import OpenSlideWSIReader, VirtualWSIReader, WSIReader @@ -166,6 +167,7 @@ def __init__( # noqa: PLR0915 self.route("/tileserver/prop_range", methods=["PUT"])(self.prop_range) self.route("/tileserver/channels", methods=["GET"])(self.get_channels) self.route("/tileserver/channels", methods=["PUT"])(self.set_channels) + self.route("/tileserver/enhance", methods=["PUT"])(self.set_enhance) self.route("/tileserver/shutdown", methods=["POST"])(self.shutdown) def _get_session_id(self: TileServer) -> str: @@ -723,13 +725,32 @@ def prop_range(self: TileServer) -> str: def get_channels(self: TileServer) -> Response: """Get the channels of the slide.""" session_id = self._get_session_id() - return jsonify(self.layers[session_id]["slide"].post_proc.color_dict) + if isinstance(self.layers[session_id]["slide"].post_proc, MultichannelToRGB): + return jsonify( + { + "channels": self.layers[session_id]["slide"].post_proc.color_dict, + "active": self.layers[session_id]["slide"].post_proc.channels, + }, + ) + return jsonify({"channels": {}, "active": []}) def set_channels(self: TileServer) -> str: """Set the channels of the slide.""" session_id = self._get_session_id() - channels = json.loads(request.form["channels"]) - self.layers[session_id]["slide"].post_proc.color_dict = channels + if isinstance(self.layers[session_id]["slide"].post_proc, MultichannelToRGB): + channels = json.loads(request.form["channels"]) + active = json.loads(request.form["active"]) + self.layers[session_id]["slide"].post_proc.color_dict = channels + self.layers[session_id]["slide"].post_proc.channels = active + self.layers[session_id]["slide"].post_proc.is_validated = False + return "done" + + def set_enhance(self: TileServer) -> str: + """Set the enhance factor of the slide.""" + session_id = self._get_session_id() + enhance = json.loads(request.form["val"]) + if isinstance(self.layers[session_id]["slide"].post_proc, MultichannelToRGB): + self.layers[session_id]["slide"].post_proc.enhance = enhance return "done" @staticmethod From 6970bae289d5b555d33ca55d70b676109d8774a1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:10:48 +0000 Subject: [PATCH 22/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_app_bokeh.py | 8 ++++---- tiatoolbox/visualization/bokeh_app/main.py | 7 +++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py index e8eec47ec..061930bd9 100644 --- a/tests/test_app_bokeh.py +++ b/tests/test_app_bokeh.py @@ -11,19 +11,19 @@ from pathlib import Path from typing import TYPE_CHECKING +import bokeh.models as bkmodels import matplotlib.pyplot as plt import numpy as np import pytest import requests +from bokeh.application import Application +from bokeh.application.handlers import FunctionHandler +from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from flask_cors import CORS from matplotlib import colormaps from PIL import Image from scipy.ndimage import label -import bokeh.models as bkmodels -from bokeh.application import Application -from bokeh.application.handlers import FunctionHandler -from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from tiatoolbox.data import _fetch_remote_sample from tiatoolbox.visualization.bokeh_app import main from tiatoolbox.visualization.tileserver import TileServer diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index f679bbaf1..8f1c1a948 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -14,10 +14,6 @@ import numpy as np import requests import torch -from matplotlib import colormaps -from PIL import Image -from requests.adapters import HTTPAdapter, Retry - from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from bokeh.io import curdoc from bokeh.layouts import column, row @@ -64,6 +60,9 @@ from bokeh.models.tiles import WMTSTileSource from bokeh.plotting import figure from bokeh.util import token +from matplotlib import colormaps +from PIL import Image +from requests.adapters import HTTPAdapter, Retry # GitHub actions seems unable to find TIAToolbox unless this is here sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) From 063aba8dd6561d8fdda6af2fb1960019c6b62b07 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Thu, 22 Aug 2024 22:05:59 +0100 Subject: [PATCH 23/36] fix a test --- tiatoolbox/visualization/bokeh_app/main.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index f679bbaf1..2a051e098 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -145,7 +145,7 @@ def get_channel_info() -> dict[str, tuple[int, int, int]]: resp = UI["s"].get(f"http://{host2}:5000/tileserver/channels") try: resp = json.loads(resp.text) - return resp["channels"], resp["active"] + return resp.get("channels", {}), resp.get("active", []) except json.JSONDecodeError: return {}, [] @@ -272,12 +272,13 @@ def populate_table() -> None: source = UI["channel_select"].children[1].children[0].source colors, active_channels = get_channel_info() - new_data = { - "channels": list(colors.keys()), - "colors": [rgb2hex(color) for color in colors.values()], - "active": [channel in active_channels for channel in range(len(colors))], - } - source.data = new_data + if colors is not None: + new_data = { + "channels": list(colors.keys()), + "colors": [rgb2hex(color) for color in colors.values()], + "active": [channel in active_channels for channel in range(len(colors))], + } + source.data = new_data def get_view_bounds( From 00eb70e8269665b682c29a781b14fc2618b6c551 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Sun, 25 Aug 2024 16:25:23 +0100 Subject: [PATCH 24/36] more efficient channel select ui --- tiatoolbox/visualization/bokeh_app/main.py | 118 +++++++++++++-------- 1 file changed, 71 insertions(+), 47 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index da0def679..3d6b3eee0 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -22,7 +22,6 @@ BoxEditTool, Button, CheckboxButtonGroup, - CheckboxEditor, Circle, ColorBar, ColorPicker, @@ -159,43 +158,64 @@ def set_channel_info( ) -def create_channel_color_ui() -> Column: - """Create the channel select/color management UI.""" - # Start with an empty ColumnDataSource - source = ColumnDataSource(data={"channels": [], "colors": [], "active": []}) +def create_channel_color_ui(): + channel_source = ColumnDataSource( + data={ + "channels": [], + "dummy": [], + } + ) + color_source = ColumnDataSource( + data={ + "colors": [], + "dummy": [], + } + ) - # Custom formatter for the color column color_formatter = HTMLTemplateFormatter( - template="""
<%= value %>
""" + template='
<%= value %>
' ) - columns = [ - TableColumn( - field="channels", title="Channel", editor=StringEditor(), sortable=False - ), - TableColumn( - field="colors", - title="Color", - editor=StringEditor(), - formatter=color_formatter, - sortable=False, - ), - TableColumn( - field="active", title="Active", editor=CheckboxEditor(), sortable=False - ), - ] - data_table = DataTable( - source=source, columns=columns, editable=True, width=250, height=200 + channel_table = DataTable( + source=channel_source, + columns=[ + TableColumn( + field="channels", title="Channel", editor=StringEditor(), sortable=False + ) + ], + editable=True, + width=200, + height=300, + selectable="checkbox", + autosize_mode="fit_viewport", + flow_mode="inline", + ) + color_table = DataTable( + source=color_source, + columns=[ + TableColumn( + field="colors", + title="Color", + formatter=color_formatter, + editor=StringEditor(), + sortable=False, + ) + ], + editable=True, + width=130, + height=300, + selectable=True, + autosize_mode="none", + index_position=None, + flow_mode="inline", ) - color_picker = ColorPicker(title="Selected Color", width=100) + color_picker = ColorPicker(title="Selected Channel Color", width=100) - def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 - """Channel color picker callback.""" - selected = source.selected.indices + def update_selected_color(attr, old, new): + selected = color_source.selected.indices if selected: - source.patch({"colors": [(selected[0], new)]}) + color_source.patch({"colors": [(selected[0], new)]}) color_picker.on_change("color", update_selected_color) @@ -205,26 +225,22 @@ def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG00 def apply_changes() -> None: """Apply the changes to the image.""" - data = source.data - colors = dict(zip(data["channels"], data["colors"])) - active_channels = [ - channel for channel, is_active in enumerate(data["active"]) if is_active - ] + colors = dict(zip(channel_source.data["channels"], color_source.data["colors"])) + active_channels = channel_source.selected.indices set_channel_info({ch: hex2rgb(colors[ch]) for ch in colors}, active_channels) change_tiles("slide") apply_button.on_click(apply_changes) - def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 - """Update the color picker when a new row is selected.""" + def update_color_picker(attr, old, new): if new: - selected_color = source.data["colors"][new[0]] + selected_color = color_source.data["colors"][new[0]] color_picker.color = selected_color else: color_picker.color = None - source.selected.on_change("indices", update_color_picker) + color_source.selected.on_change("indices", update_color_picker) enhance_slider = Slider( start=0.1, @@ -250,10 +266,11 @@ def enhance_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: text="""

Instructions:

    -
  • Use the table to view and edit channel properties
  • -
  • Click on a row to select a channel
  • +
  • Double-click on the 'Active' column to toggle channel visibility
  • +
  • Click on a row to select it for color editing
  • +
  • Use 'Select All' or 'Deselect All' for quick selection
  • +
  • Enable 'Solo Mode' and select a channel to view it alone
  • Use the color picker to change the color of the selected channel
  • -
  • Check or uncheck the 'Active' column to toggle channel visibility
  • Click 'Apply Changes' to update the image
""" @@ -261,23 +278,30 @@ def enhance_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: return column( instructions, - column(data_table, row(color_picker, apply_button), enhance_slider), + column( + row(channel_table, color_table), + row(color_picker, apply_button), + enhance_slider, + ), ) def populate_table() -> None: """Populate the channel color table.""" # Access the ColumnDataSource from the UI dictionary - source = UI["channel_select"].children[1].children[0].source + tables = UI["channel_select"].children[1].children[0].children colors, active_channels = get_channel_info() if colors is not None: - new_data = { + tables[0].source.data = { "channels": list(colors.keys()), + "dummy": list(colors.keys()), + } + tables[1].source.data = { "colors": [rgb2hex(color) for color in colors.values()], - "active": [channel in active_channels for channel in range(len(colors))], + "dummy": list(colors.keys()), } - source.data = new_data + tables[0].source.selected.indices = active_channels def get_view_bounds( From 883ce4036f36055b509656d32bd4221fb624f626 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Thu, 29 Aug 2024 13:53:24 +0100 Subject: [PATCH 25/36] multichannel ui layout tweak --- tiatoolbox/visualization/bokeh_app/main.py | 26 +++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 3d6b3eee0..a895a5d2d 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -14,6 +14,10 @@ import numpy as np import requests import torch +from matplotlib import colormaps +from PIL import Image +from requests.adapters import HTTPAdapter, Retry + from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from bokeh.io import curdoc from bokeh.layouts import column, row @@ -59,9 +63,6 @@ from bokeh.models.tiles import WMTSTileSource from bokeh.plotting import figure from bokeh.util import token -from matplotlib import colormaps -from PIL import Image -from requests.adapters import HTTPAdapter, Retry # GitHub actions seems unable to find TIAToolbox unless this is here sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) @@ -180,15 +181,19 @@ def create_channel_color_ui(): source=channel_source, columns=[ TableColumn( - field="channels", title="Channel", editor=StringEditor(), sortable=False + field="channels", + title="Channel", + editor=StringEditor(), + sortable=False, + width=200, ) ], editable=True, width=200, - height=300, + height=260, selectable="checkbox", - autosize_mode="fit_viewport", - flow_mode="inline", + autosize_mode="none", + fit_columns=True, ) color_table = DataTable( source=color_source, @@ -199,18 +204,19 @@ def create_channel_color_ui(): formatter=color_formatter, editor=StringEditor(), sortable=False, + width=130, ) ], editable=True, width=130, - height=300, + height=260, selectable=True, autosize_mode="none", index_position=None, - flow_mode="inline", + fit_columns=True, ) - color_picker = ColorPicker(title="Selected Channel Color", width=100) + color_picker = ColorPicker(title="Channel Color", width=100) def update_selected_color(attr, old, new): selected = color_source.selected.indices From 9df1566355c66b0005cb5541f4ed4fec7e0fd52d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Aug 2024 12:57:12 +0000 Subject: [PATCH 26/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tiatoolbox/visualization/bokeh_app/main.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index a895a5d2d..fbfe1e3cd 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -14,10 +14,6 @@ import numpy as np import requests import torch -from matplotlib import colormaps -from PIL import Image -from requests.adapters import HTTPAdapter, Retry - from bokeh.events import ButtonClick, DoubleTap, MenuItemClick from bokeh.io import curdoc from bokeh.layouts import column, row @@ -63,6 +59,9 @@ from bokeh.models.tiles import WMTSTileSource from bokeh.plotting import figure from bokeh.util import token +from matplotlib import colormaps +from PIL import Image +from requests.adapters import HTTPAdapter, Retry # GitHub actions seems unable to find TIAToolbox unless this is here sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) From edd6f63952a8750d3a4fabbf7f8da9988af776de Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Sun, 22 Sep 2024 00:01:51 +0100 Subject: [PATCH 27/36] robustify level sort --- tiatoolbox/models/dataset/classification.py | 6 +++++- tiatoolbox/wsicore/wsireader.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tiatoolbox/models/dataset/classification.py b/tiatoolbox/models/dataset/classification.py index cd17dab7e..44a43e318 100644 --- a/tiatoolbox/models/dataset/classification.py +++ b/tiatoolbox/models/dataset/classification.py @@ -320,7 +320,11 @@ def __init__( # skipcq: PY-R1000 # noqa: PLR0915 elif auto_get_mask and mode == "wsi" and mask_path is None: # if no mask provided and `wsi` mode, generate basic tissue # mask on the fly - mask_reader = self.reader.tissue_mask(resolution=1.25, units="power") + try: + mask_reader = self.reader.tissue_mask(resolution=1.25, units="power") + except: + # if power is None, try with mpp + mask_reader = self.reader.tissue_mask(resolution=6.0, units="mpp") # ? will this mess up ? mask_reader.info = self.reader.info diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index 4e2312aa5..3b5dde262 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -3595,7 +3595,9 @@ def page_area(page: tifffile.TiffPage) -> float: self.level_arrays = dict( sorted( self.level_arrays.items(), - key=lambda x: -np.prod(self._canonical_shape(x[1].array.shape[:2])), + key=lambda x: -np.prod( + self._canonical_shape(x[1].array.shape[:2]), dtype=float + ), ) ) # maybe get colors if they exist in metadata From 3a024dd89a3e8b805a8ac063c043f58f2bd98004 Mon Sep 17 00:00:00 2001 From: behnazelhaminia Date: Thu, 26 Sep 2024 18:49:32 +0100 Subject: [PATCH 28/36] :white_check_mark: Adding small multiplxed image --- tiatoolbox/data/remote_samples.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tiatoolbox/data/remote_samples.yaml b/tiatoolbox/data/remote_samples.yaml index 5182c87a4..108be3d6b 100644 --- a/tiatoolbox/data/remote_samples.yaml +++ b/tiatoolbox/data/remote_samples.yaml @@ -147,3 +147,5 @@ files: url: [*modelroot, "predictions/nuclei_mask/nuclick-output.npy"] qptiff_sample: url: [*wsis, "multiplexed_example.qptiff"] + qptiff_sample_small: + url: [ *wsis, "multiplexed_example_small.qptiff" ] From 9d8ac0cdec35ea58ea6350c77d30c7830c6a2617 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:03:33 +0000 Subject: [PATCH 29/36] dataset fix --- tiatoolbox/models/dataset/classification.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tiatoolbox/models/dataset/classification.py b/tiatoolbox/models/dataset/classification.py index 44a43e318..7180c0265 100644 --- a/tiatoolbox/models/dataset/classification.py +++ b/tiatoolbox/models/dataset/classification.py @@ -352,6 +352,8 @@ def __getitem__(self: WSIPatchDataset, idx: int) -> dict: """Get an item from the dataset.""" coords = self.inputs[idx] # Read image patch from the whole-slide image + if isinstance(self.reader, (Path, str)): + self.reader = WSIReader.open(self.reader) patch = self.reader.read_bounds( coords, resolution=self.resolution, From 41d2fee1d463c3bc728386316b932505e4f98d26 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:05:35 +0000 Subject: [PATCH 30/36] fix get_channels and address ruff --- tiatoolbox/visualization/bokeh_app/main.py | 15 ++++++++++----- tiatoolbox/visualization/tileserver.py | 4 ++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index c441624f6..6567eb85f 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -145,7 +145,8 @@ def get_channel_info() -> dict[str, tuple[int, int, int]]: try: resp = json.loads(resp.text) return resp.get("channels", {}), resp.get("active", []) - except json.JSONDecodeError: + except json.JSONDecodeError as e: + logger.warning(f"Error decoding JSON: {e}") return {}, [] @@ -159,7 +160,8 @@ def set_channel_info( ) -def create_channel_color_ui(): +def create_channel_color_ui() -> Column: + """Create the multi-channel UI controls.""" channel_source = ColumnDataSource( data={ "channels": [], @@ -174,7 +176,8 @@ def create_channel_color_ui(): ) color_formatter = HTMLTemplateFormatter( - template='
<%= value %>
' + template="""
<%= value %>
""" ) channel_table = DataTable( @@ -218,7 +221,8 @@ def create_channel_color_ui(): color_picker = ColorPicker(title="Channel Color", width=100) - def update_selected_color(attr, old, new): + def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Update the selected color in multichannel ui.""" selected = color_source.selected.indices if selected: color_source.patch({"colors": [(selected[0], new)]}) @@ -239,7 +243,8 @@ def apply_changes() -> None: apply_button.on_click(apply_changes) - def update_color_picker(attr, old, new): + def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Update the color picker when a new channel is selected.""" if new: selected_color = color_source.data["colors"][new[0]] color_picker.color = selected_color diff --git a/tiatoolbox/visualization/tileserver.py b/tiatoolbox/visualization/tileserver.py index 5b637e169..26b480643 100644 --- a/tiatoolbox/visualization/tileserver.py +++ b/tiatoolbox/visualization/tileserver.py @@ -726,6 +726,10 @@ def get_channels(self: TileServer) -> Response: """Get the channels of the slide.""" session_id = self._get_session_id() if isinstance(self.layers[session_id]["slide"].post_proc, MultichannelToRGB): + if self.layers[session_id]["slide"].post_proc.color_dict is None: + _ = self.layers[session_id]["slide"].slide_thumbnail( + resolution=8.0, units="mpp" + ) return jsonify( { "channels": self.layers[session_id]["slide"].post_proc.color_dict, From 04259174a9e34a04d6e0e41338c84454ddda1fcc Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:26:49 +0000 Subject: [PATCH 31/36] precommit fixes --- tiatoolbox/models/dataset/classification.py | 2 +- tiatoolbox/visualization/bokeh_app/main.py | 22 +++++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/tiatoolbox/models/dataset/classification.py b/tiatoolbox/models/dataset/classification.py index 7180c0265..4881dc344 100644 --- a/tiatoolbox/models/dataset/classification.py +++ b/tiatoolbox/models/dataset/classification.py @@ -322,7 +322,7 @@ def __init__( # skipcq: PY-R1000 # noqa: PLR0915 # mask on the fly try: mask_reader = self.reader.tissue_mask(resolution=1.25, units="power") - except: + except ValueError: # if power is None, try with mpp mask_reader = self.reader.tissue_mask(resolution=6.0, units="mpp") # ? will this mess up ? diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 6567eb85f..2c20bcc02 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -146,7 +146,7 @@ def get_channel_info() -> dict[str, tuple[int, int, int]]: resp = json.loads(resp.text) return resp.get("channels", {}), resp.get("active", []) except json.JSONDecodeError as e: - logger.warning(f"Error decoding JSON: {e}") + logger.warning("Error decoding JSON: %s", e) return {}, [] @@ -221,7 +221,11 @@ def create_channel_color_ui() -> Column: color_picker = ColorPicker(title="Channel Color", width=100) - def update_selected_color(attr: str, old: str, new: str) -> None: # noqa: ARG001 + def update_selected_color( # noqa: ARG001 # skipcq: PYL-W0613 + attr: str, + old: str, + new: str + ) -> None: """Update the selected color in multichannel ui.""" selected = color_source.selected.indices if selected: @@ -243,7 +247,10 @@ def apply_changes() -> None: apply_button.on_click(apply_changes) - def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 + def update_color_picker( # noqa: ARG001 # skipcq: PYL-W0613 + attr: str, + old: str, + new: str) -> None: """Update the color picker when a new channel is selected.""" if new: selected_color = color_source.data["colors"][new[0]] @@ -262,7 +269,10 @@ def update_color_picker(attr: str, old: str, new: str) -> None: # noqa: ARG001 width=200, ) - def enhance_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 + def enhance_cb( # noqa: ARG001 # skipcq: PYL-W0613 + attr: str, + old: str, + new: str) -> None: """Enhance slider callback.""" UI["s"].put( f"http://{host2}:5000/tileserver/enhance", @@ -929,14 +939,14 @@ def populate_slide_list(slide_folder: Path, search_txt: str | None = None) -> No UI["slide_select"].options = file_list -def filter_input_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 +def filter_input_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 """Change predicate to be used to filter annotations.""" build_predicate() UI["vstate"].update_state = 1 UI["vstate"].to_update.update(["overlay"]) -def cprop_input_cb(attr: str, old: str, new: list[str]) -> None: # noqa: ARG001 +def cprop_input_cb(attr: str, old: str, new: list[str]) -> None: # noqa: ARG001 # skipcq: PYL-W0613 """Change property to color by.""" if len(new) == 0: return From e4b44d75c2a35f9497b1952655cc7926fefabeca Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:04:18 +0000 Subject: [PATCH 32/36] dont fix arg001 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2d29ca7c8..f807b6593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ lint.select = [ "ASYNC", # flake8-async ] # Ignore rules which conflict with ruff formatter. -lint.ignore = ["COM812", "ISC001",] +lint.ignore = ["COM812", "ISC001", "RUF100"] # Allow Ruff to discover `*.ipynb` files. include = ["*.py", "*.pyi", "**/pyproject.toml", "*.ipynb"] From 4ca6660f7027d060831780e830aa0eba3668bcbe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:05:12 +0000 Subject: [PATCH 33/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tiatoolbox/visualization/bokeh_app/main.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 2c20bcc02..8feb9d13a 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -221,11 +221,9 @@ def create_channel_color_ui() -> Column: color_picker = ColorPicker(title="Channel Color", width=100) - def update_selected_color( # noqa: ARG001 # skipcq: PYL-W0613 - attr: str, - old: str, - new: str - ) -> None: + def update_selected_color( # noqa: ARG001 # skipcq: PYL-W0613 + attr: str, old: str, new: str + ) -> None: """Update the selected color in multichannel ui.""" selected = color_source.selected.indices if selected: @@ -248,9 +246,8 @@ def apply_changes() -> None: apply_button.on_click(apply_changes) def update_color_picker( # noqa: ARG001 # skipcq: PYL-W0613 - attr: str, - old: str, - new: str) -> None: + attr: str, old: str, new: str + ) -> None: """Update the color picker when a new channel is selected.""" if new: selected_color = color_source.data["colors"][new[0]] @@ -270,9 +267,8 @@ def update_color_picker( # noqa: ARG001 # skipcq: PYL-W0613 ) def enhance_cb( # noqa: ARG001 # skipcq: PYL-W0613 - attr: str, - old: str, - new: str) -> None: + attr: str, old: str, new: str + ) -> None: """Enhance slider callback.""" UI["s"].put( f"http://{host2}:5000/tileserver/enhance", From 312e5b502b540296a94c2eea3b9d69644534548d Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:18:07 +0000 Subject: [PATCH 34/36] precommit.. --- tiatoolbox/visualization/bokeh_app/main.py | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 8feb9d13a..dc3af9314 100644 --- a/tiatoolbox/visualization/bokeh_app/main.py +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -221,8 +221,10 @@ def create_channel_color_ui() -> Column: color_picker = ColorPicker(title="Channel Color", width=100) - def update_selected_color( # noqa: ARG001 # skipcq: PYL-W0613 - attr: str, old: str, new: str + def update_selected_color( + attr: str, # noqa: ARG001 # skipcq: PYL-W0613 + old: str, # noqa: ARG001 # skipcq: PYL-W0613 + new: str, ) -> None: """Update the selected color in multichannel ui.""" selected = color_source.selected.indices @@ -245,8 +247,10 @@ def apply_changes() -> None: apply_button.on_click(apply_changes) - def update_color_picker( # noqa: ARG001 # skipcq: PYL-W0613 - attr: str, old: str, new: str + def update_color_picker( + attr: str, # noqa: ARG001 # skipcq: PYL-W0613 + old: str, # noqa: ARG001 # skipcq: PYL-W0613 + new: str, ) -> None: """Update the color picker when a new channel is selected.""" if new: @@ -266,8 +270,10 @@ def update_color_picker( # noqa: ARG001 # skipcq: PYL-W0613 width=200, ) - def enhance_cb( # noqa: ARG001 # skipcq: PYL-W0613 - attr: str, old: str, new: str + def enhance_cb( + attr: str, # noqa: ARG001 # skipcq: PYL-W0613 + old: str, # noqa: ARG001 # skipcq: PYL-W0613 + new: str, ) -> None: """Enhance slider callback.""" UI["s"].put( @@ -935,14 +941,22 @@ def populate_slide_list(slide_folder: Path, search_txt: str | None = None) -> No UI["slide_select"].options = file_list -def filter_input_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 # skipcq: PYL-W0613 +def filter_input_cb( + attr: str, # noqa: ARG001 # skipcq: PYL-W0613 + old: str, # noqa: ARG001 # skipcq: PYL-W0613 + new: str, # noqa: ARG001 # skipcq: PYL-W0613 +) -> None: """Change predicate to be used to filter annotations.""" build_predicate() UI["vstate"].update_state = 1 UI["vstate"].to_update.update(["overlay"]) -def cprop_input_cb(attr: str, old: str, new: list[str]) -> None: # noqa: ARG001 # skipcq: PYL-W0613 +def cprop_input_cb( + attr: str, # noqa: ARG001 # skipcq: PYL-W0613 + old: str, # noqa: ARG001 # skipcq: PYL-W0613 + new: list[str], +) -> None: """Change property to color by.""" if len(new) == 0: return From 68c375cd7ddafe48abb09a4de2b224cf1dacd90e Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 14 Feb 2025 05:43:22 +0000 Subject: [PATCH 35/36] comet format support --- tiatoolbox/utils/postproc_defs.py | 10 +++++++--- tiatoolbox/wsicore/wsireader.py | 10 ++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tiatoolbox/utils/postproc_defs.py b/tiatoolbox/utils/postproc_defs.py index a0954384c..54ca54ccc 100644 --- a/tiatoolbox/utils/postproc_defs.py +++ b/tiatoolbox/utils/postproc_defs.py @@ -4,6 +4,7 @@ import colorsys import warnings +from typing import Any import numpy as np @@ -13,7 +14,7 @@ class MultichannelToRGB: def __init__( self: MultichannelToRGB, - color_dict: list[tuple[float, float, float]] | None = None, + color_dict: dict[str : tuple[float, float, float]] | None = None, ) -> None: """Initialize the MultichannelToRGB converter. @@ -60,7 +61,7 @@ def validate(self: MultichannelToRGB, n: int) -> None: msg = f"Number of colors: {n_colors} does not match channels in image: {n}." raise ValueError(msg) - def generate_colors(self: MultichannelToRGB, n_channels: int) -> np.ndarray: + def generate_colors(self: MultichannelToRGB, n_channels: int) -> None: """Generate a set of visually distinct colors. Args: @@ -97,6 +98,9 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: if not self.is_validated: self.validate(n) + if image.dtype == np.uint16: + image = (image / 256).astype(np.uint8) + # Convert to RGB image rgb_image = ( np.einsum( @@ -111,7 +115,7 @@ def __call__(self: MultichannelToRGB, image: np.ndarray) -> np.ndarray: # Clip to ensure in valid range and return return np.clip(rgb_image, 0, 255).astype(np.uint8) - def __setattr__(self: MultichannelToRGB, name: str, value: np.Any) -> None: + def __setattr__(self: MultichannelToRGB, name: str, value: Any) -> None: """Ensure that colors is updated if color_dict is updated.""" if name == "color_dict" and value is not None: self.colors = np.array(list(value.values()), dtype=np.float32) diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index d52634707..f3c2de13e 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -409,9 +409,12 @@ def open( # noqa: PLR0911 return TIFFWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc) if last_suffix in (".tif", ".tiff"): - tiff_wsi = _handle_tiff_wsi( + tiff_wsi = TIFFWSIReader( input_path, mpp=mpp, power=power, post_proc=post_proc ) + # tiff_wsi = _handle_tiff_wsi( + # input_path, mpp=mpp, power=power, post_proc=post_proc + # ) if tiff_wsi is not None: return tiff_wsi @@ -3792,8 +3795,11 @@ def _get_ome_objective_power( return None objective_settings = xml_series.find("ome:ObjectiveSettings", namespaces) + if objective_settings is None: + # try alternative tag + objective_settings = xml_series.find("ome:Objective", namespaces) instrument_ref_id = instrument_ref.attrib["ID"] - objective_settings_id = objective_settings.attrib["ID"] + objective_settings_id = "Objective:0" # objective_settings.attrib["ID"] instruments = { instrument.attrib["ID"]: instrument for instrument in xml.findall("ome:Instrument", namespaces) From b228d78ce41db7e13bee269c7580a83ad2930988 Mon Sep 17 00:00:00 2001 From: measty <20169086+measty@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:18:11 +0000 Subject: [PATCH 36/36] metadata update --- tiatoolbox/wsicore/wsireader.py | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index f3c2de13e..ecd4a3357 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -412,6 +412,8 @@ def open( # noqa: PLR0911 tiff_wsi = TIFFWSIReader( input_path, mpp=mpp, power=power, post_proc=post_proc ) + # temporary force to use TIFFWSIReader for tiffs as openslide doesnt work with comet + # remove before merging # tiff_wsi = _handle_tiff_wsi( # input_path, mpp=mpp, power=power, post_proc=post_proc # ) @@ -3633,6 +3635,64 @@ def _get_colors_from_meta(self: TIFFWSIReader) -> None: else: color_dict[key] = mcolors.to_rgb(value) self.post_proc.color_dict = color_dict + return + + # try alternate metadata format + # Build a map from filter pair string -> color label or RGB string + # from the section + filter_colors = {} + filter_colors_section = root.find(".//FilterColors") + if filter_colors_section is not None: + keys = filter_colors_section.findall(".//FilterColors-k") + vals = filter_colors_section.findall(".//FilterColors-v") + for k, v in zip(keys, vals): + filter_colors[k.text] = v.text + + # Helper function to convert color strings like "Lime" or "255, 128, 0" into (R,G,B) + def color_string_to_rgb(s): + if "," in s: + return tuple(int(x.strip()) / 255 for x in s.split(",")) + return mcolors.to_rgb(s) + + # 2) For each , find the channel's name and figure out + # which filter pair it uses, then match that to a color. + channel_dict = {} + + for scan_band in root.findall(".//ScanBands-i"): + # Inside a there is a with a tag + bands_i = scan_band.find(".//Bands-i") + if bands_i is not None: + band_name_element = bands_i.find("Name") + if band_name_element is not None: + channel_name = band_name_element.text.strip() + + # Grab the filter pair manufacturer info + filter_pair = scan_band.find(".//FilterPair") + if filter_pair is not None: + emission_part = filter_pair.find( + ".//EmissionFilter/FixedFilter/PartNumber" + ) + excitation_part = filter_pair.find( + ".//ExcitationFilter/FixedFilter/PartNumber" + ) + if ( + emission_part is not None + and excitation_part is not None + ): + matching_rgb = (1.0, 1.0, 1.0) # default white + for fc_key, fc_val in filter_colors.items(): + # if both part numbers appear in the FilterColors-k string, assume it's the match + if ( + emission_part.text in fc_key + and excitation_part.text in fc_key + ): + matching_rgb = color_string_to_rgb(fc_val) + break + + channel_dict[channel_name] = matching_rgb + + if len(channel_dict) > 0: + self.post_proc.color_dict = channel_dict def _canonical_shape(self: TIFFWSIReader, shape: IntPair) -> tuple: """Make a level shape tuple in YXS order.