From 467ed55ec719cbfa26ff57c6c8c0692dc2925ba4 Mon Sep 17 00:00:00 2001 From: Ycblue Date: Fri, 6 Jan 2023 12:49:14 +0100 Subject: [PATCH 1/6] added HEDJitter and HEDJitterd classes --- .../apps/pathology/transforms/stain/array.py | 60 +++++++++++++++++++ .../pathology/transforms/stain/dictionary.py | 28 ++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 3b3a293451..b2461b740e 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -12,10 +12,70 @@ from typing import Union import numpy as np +from skimage import color from monai.transforms.transform import Transform +class HEDJitter(Transform): + """Randomly perturbe the HED color space value an RGB image. + First, it disentangled the hematoxylin and eosin color channels by color deconvolution method using a fixed matrix. + Second, it perturbed the hematoxylin, eosin and DAB stains independently. + Third, it transformed the resulting stains into regular RGB color space. + Args: + theta (float): How much to jitter HED color space, + alpha is chosen from a uniform distribution [1-theta, 1+theta] + betti is chosen from a uniform distribution [-theta, theta] + the jitter formula is **s' = \alpha * s + \betti** + + Note: + For more information refer to: + - the paper benchmarking different stain augmentation and normalization methods including HED: Tellez et al. 2020 https://arxiv.org/abs/1902.06543 + - the original paper on color deconvolution: Ruifrok et al. 2001 https://helios2.mi.parisdescartes.fr/~lomn/Cours/CV/BME/HistoPatho/Color/PythonColorDeconv/Quantification_of_histochemical_staining.pdf + - previous Pytorch implementation: https://github.com/gatsby2016/Augmentation-PyTorch-Transforms + + """ + + def __init__(self, theta: float = 0.0) -> None: + assert isinstance(theta, numbers.Number), "theta should be a single number." + self.theta = theta + self.alpha = np.random.uniform(1 - theta, 1 + theta, (1, 3)) + self.betti = np.random.uniform(-theta, theta, (1, 3)) + + def adjust_HED(img, alpha, betti): + # img = np.array(img) + + s = np.reshape(color.rgb2hed(img), (-1, 3)) + ns = alpha * s + betti # perturbations on HED color space + nimg = color.hed2rgb(np.reshape(ns, img.shape)) + + imin = nimg.min() + imax = nimg.max() + rsimg = (255 * (nimg - imin) / (imax - imin)).astype("uint8") # rescale to [0,255] + # transfer to PIL image + return rsimg + + def __call__(self, image: np.ndarray) -> np.ndarray: + """ + Args: + image: uint8 RGB image to perform HEDJitter on. + + Returns: + perturbed_image: uint8 RGB image HED preturbed image. + """ + if not isinstance(image, np.ndarray): + raise TypeError("Image must be of type numpy.ndarray.") + perturbed_image = self.adjust_HED(image, self.alpha, self.betti) + return perturbed_image + + def __repr__(self): + format_string = self.__class__.__name__ + "(" + format_string += "theta={0}".format(self.theta) + format_string += ",alpha={0}".format(self.alpha) + format_string += ",betti={0}".format(self.betti) + return format_string + + class ExtractHEStains(Transform): """Class to extract a target stain from an image, using stain deconvolution (see Note). diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index eb8eba43f8..fddebf37e1 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -22,7 +22,32 @@ from monai.config import KeysCollection from monai.transforms.transform import MapTransform -from .array import ExtractHEStains, NormalizeHEStains +from .array import ExtractHEStains, HEDJitter, NormalizeHEStains + + +class HEDJitterd(MapTransform): + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.HEDJitter`. + Class to randomly perturbe HED color space values of image using stain deconvolution. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + theta: jitter range factor + + allow_missing_keys: don't raise exception if key is missing. + + """ + + def __init__(self, keys: KeysCollection, theta: float = 0.0) -> None: + super().__init__(keys, allow_missing_keys) + assert isinstance(theta, numbers.Number), "theta should be a single number." + self.hedjitter = HEDJitter(theta=theta) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.hedjitter(d[key]) + return d class ExtractHEStainsd(MapTransform): @@ -109,3 +134,4 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda ExtractHEStainsDict = ExtractHEStainsD = ExtractHEStainsd NormalizeHEStainsDict = NormalizeHEStainsD = NormalizeHEStainsd +HEDJitterDict = HEDJitterD = HEDJitterd From 8a46fef593a22d71f598387d296efc2f95f59e93 Mon Sep 17 00:00:00 2001 From: Ycblue Date: Fri, 6 Jan 2023 14:56:28 +0100 Subject: [PATCH 2/6] optional import Signed-off-by: Ycblue --- monai/apps/pathology/transforms/stain/array.py | 7 +++++-- monai/apps/pathology/transforms/stain/dictionary.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index b2461b740e..28f9ac1c8e 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -13,6 +13,9 @@ import numpy as np from skimage import color +from monai.utils import optional_import + +skimage = optional_import('skimage') from monai.transforms.transform import Transform @@ -45,9 +48,9 @@ def __init__(self, theta: float = 0.0) -> None: def adjust_HED(img, alpha, betti): # img = np.array(img) - s = np.reshape(color.rgb2hed(img), (-1, 3)) + s = np.reshape(skimage.color.rgb2hed(img), (-1, 3)) ns = alpha * s + betti # perturbations on HED color space - nimg = color.hed2rgb(np.reshape(ns, img.shape)) + nimg = skimage.color.hed2rgb(np.reshape(ns, img.shape)) imin = nimg.min() imax = nimg.max() diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index fddebf37e1..0b366ac294 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -26,6 +26,7 @@ class HEDJitterd(MapTransform): + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.HEDJitter`. Class to randomly perturbe HED color space values of image using stain deconvolution. From 61d620e369dfe5ddd684f2b09b645c6f284d3b4d Mon Sep 17 00:00:00 2001 From: Ycblue Date: Tue, 14 Feb 2023 21:31:47 +0100 Subject: [PATCH 3/6] unit tests passed Signed-off-by: Ycblue --- .../apps/pathology/transforms/stain/array.py | 22 +- .../pathology/transforms/stain/dictionary.py | 5 +- tests/test_pathology_hedjitter.py | 221 ++++++++++++++++++ 3 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 tests/test_pathology_hedjitter.py diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 28f9ac1c8e..f35c04a74e 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -9,15 +9,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numbers from typing import Union import numpy as np -from skimage import color -from monai.utils import optional_import - -skimage = optional_import('skimage') from monai.transforms.transform import Transform +from monai.utils import optional_import + +color, _ = optional_import("skimage", name="color") class HEDJitter(Transform): @@ -33,8 +33,10 @@ class HEDJitter(Transform): Note: For more information refer to: - - the paper benchmarking different stain augmentation and normalization methods including HED: Tellez et al. 2020 https://arxiv.org/abs/1902.06543 - - the original paper on color deconvolution: Ruifrok et al. 2001 https://helios2.mi.parisdescartes.fr/~lomn/Cours/CV/BME/HistoPatho/Color/PythonColorDeconv/Quantification_of_histochemical_staining.pdf + - the paper benchmarking different stain augmentation and normalization methods including HED: + Tellez et al. 2020 https://arxiv.org/abs/1902.06543 + - the original paper on color deconvolution: Ruifrok et al. 2001 + https://helios2.mi.parisdescartes.fr/~lomn/Cours/CV/BME/HistoPatho/Color/PythonColorDeconv/Quantification_of_histochemical_staining.pdf - previous Pytorch implementation: https://github.com/gatsby2016/Augmentation-PyTorch-Transforms """ @@ -45,12 +47,12 @@ def __init__(self, theta: float = 0.0) -> None: self.alpha = np.random.uniform(1 - theta, 1 + theta, (1, 3)) self.betti = np.random.uniform(-theta, theta, (1, 3)) - def adjust_HED(img, alpha, betti): + def adjust_hed(self, img, alpha, betti): # img = np.array(img) - s = np.reshape(skimage.color.rgb2hed(img), (-1, 3)) + s = np.reshape(color.rgb2hed(img), (-1, 3)) ns = alpha * s + betti # perturbations on HED color space - nimg = skimage.color.hed2rgb(np.reshape(ns, img.shape)) + nimg = color.hed2rgb(np.reshape(ns, img.shape)) imin = nimg.min() imax = nimg.max() @@ -68,7 +70,7 @@ def __call__(self, image: np.ndarray) -> np.ndarray: """ if not isinstance(image, np.ndarray): raise TypeError("Image must be of type numpy.ndarray.") - perturbed_image = self.adjust_HED(image, self.alpha, self.betti) + perturbed_image = self.adjust_hed(image, self.alpha, self.betti) return perturbed_image def __repr__(self): diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index 0b366ac294..06a1682d00 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -15,6 +15,7 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ +import numbers from typing import Dict, Hashable, Mapping, Union import numpy as np @@ -26,7 +27,7 @@ class HEDJitterd(MapTransform): - + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.HEDJitter`. Class to randomly perturbe HED color space values of image using stain deconvolution. @@ -39,7 +40,7 @@ class HEDJitterd(MapTransform): """ - def __init__(self, keys: KeysCollection, theta: float = 0.0) -> None: + def __init__(self, keys: KeysCollection, theta: float = 0.0, allow_missing_keys: bool = False) -> None: super().__init__(keys, allow_missing_keys) assert isinstance(theta, numbers.Number), "theta should be a single number." self.hedjitter = HEDJitter(theta=theta) diff --git a/tests/test_pathology_hedjitter.py b/tests/test_pathology_hedjitter.py new file mode 100644 index 0000000000..2115ce9a99 --- /dev/null +++ b/tests/test_pathology_hedjitter.py @@ -0,0 +1,221 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.pathology.transforms import ExtractHEStainsD, NormalizeHEStainsD + +# None inputs +EXTRACT_STAINS_TEST_CASE_0 = (None,) +EXTRACT_STAINS_TEST_CASE_00 = (None, None) +NORMALIZE_STAINS_TEST_CASE_0 = (None,) +NORMALIZE_STAINS_TEST_CASE_00: tuple = ({}, None, None) + +# input pixels all transparent and below the beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled, but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_2 = [np.full((3, 2, 3), 100)] + +# input pixels uniformly filled (different value), but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_3 = [np.full((3, 2, 3), 150)] + +# input pixels uniformly filled with zeros, leading to two identical stains extracted +EXTRACT_STAINS_TEST_CASE_4 = [ + np.zeros((3, 2, 3)), + np.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), +] + +# input pixels not uniformly filled, leading to two different stains extracted +EXTRACT_STAINS_TEST_CASE_5 = [ + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), +] + +# input pixels all transparent and below the beta absorbance threshold +NORMALIZE_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled with zeros, and target stain matrix provided +NORMALIZE_STAINS_TEST_CASE_2 = [{"target_he": np.full((3, 2), 1)}, np.zeros((3, 2, 3)), np.full((3, 2, 3), 11)] + +# input pixels uniformly filled with zeros, and target stain matrix not provided +NORMALIZE_STAINS_TEST_CASE_3 = [ + {}, + np.zeros((3, 2, 3)), + np.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), +] + +# input pixels not uniformly filled +NORMALIZE_STAINS_TEST_CASE_4 = [ + {"target_he": np.full((3, 2), 1)}, + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), +] + + +class TestExtractHEStainsD(unittest.TestCase): + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test HE stain extraction on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + with self.assertRaises(ValueError): + ExtractHEStainsD([key])({key: image}) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) + def test_identical_result_vectors(self, image): + """ + Test HE stain extraction on input images that are + uniformly filled with pixels that have absorbance above the + beta absorbance threshold. Since input image is uniformly filled, + the two extracted stains should have the same RGB values. So, + we assert that the first column is equal to the second column + of the returned stain matrix. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + result = ExtractHEStainsD([key])({key: image}) + np.testing.assert_array_equal(result[key][:, 0], result[key][:, 1]) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_00, EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) + def test_result_value(self, image, expected_data): + """ + Test that an input image returns an expected stain matrix. + + For test case 4: + - a uniformly filled input image should result in + eigenvectors [[1,0,0],[0,1,0],[0,0,1]] + - phi should be an array containing only values of + arctan(1) since the ratio between the eigenvectors + corresponding to the two largest eigenvalues is 1 + - maximum phi and minimum phi should thus be arctan(1) + - thus, maximum vector and minimum vector should be + [[0],[0.70710677],[0.70710677]] + - the resulting extracted stain should be + [[0,0],[0.70710678,0.70710678],[0.70710678,0.70710678]] + + For test case 5: + - the non-uniformly filled input image should result in + eigenvectors [[0,0,1],[1,0,0],[0,1,0]] + - maximum phi and minimum phi should thus be 0.785 and + 0.188 respectively + - thus, maximum vector and minimum vector should be + [[0.18696113],[0],[0.98236734]] and + [[0.70710677],[0],[0.70710677]] respectively + - the resulting extracted stain should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]] + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + result = ExtractHEStainsD([key])({key: image}) + np.testing.assert_allclose(result[key], expected_data) + + +class TestNormalizeHEStainsD(unittest.TestCase): + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_0, NORMALIZE_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test HE stain normalization on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStainsD([key])({key: image}) + else: + with self.assertRaises(ValueError): + NormalizeHEStainsD([key])({key: image}) + + @parameterized.expand( + [ + NORMALIZE_STAINS_TEST_CASE_00, + NORMALIZE_STAINS_TEST_CASE_2, + NORMALIZE_STAINS_TEST_CASE_3, + NORMALIZE_STAINS_TEST_CASE_4, + ] + ) + def test_result_value(self, arguments, image, expected_data): + """ + Test that an input image returns an expected normalized image. + + For test case 2: + - This case tests calling the stain normalizer, after the + _deconvolution_extract_conc function. This is because the normalized + concentration returned for each pixel is the same as the reference + maximum stain concentrations in the case that the image is uniformly + filled, as in this test case. This is because the maximum concentration + for each stain is the same as each pixel's concentration. + - Thus, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be a matrix of + dims (3, 2, 3), with all values 11. + + For test case 3: + - This case also tests calling the stain normalizer, after the + _deconvolution_extract_conc function returns the image concentration + matrix. + - As in test case 2, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target default stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be [[[63, 25, 60], [63, 25, 60]], + [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]] + + For test case 4: + - For this non-uniformly filled image, the stain extracted should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]], as validated for the + ExtractHEStains class. Solving the linear least squares problem (since + absorbance matrix = stain matrix * concentration matrix), we obtain the concentration + matrix that should be [[-0.3101, 7.7508, 7.7508, 7.7508, 7.7508, 7.7508], + [5.8022, 0, 0, 0, 0, 0]] + - Normalizing the concentration matrix, taking the matrix product of the + target stain matrix and the concentration matrix, using the inverse + Beer-Lambert transform to obtain the RGB image from the absorbance + image, and finally converting to uint8, we get that the stain normalized + image should be [[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], + [[33, 33, 33], [33, 33, 33]]] + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStainsD([key])({key: image}) + else: + result = NormalizeHEStainsD([key], **arguments)({key: image}) + np.testing.assert_allclose(result[key], expected_data) + + +if __name__ == "__main__": + unittest.main() From 9e85257f234e53eea5e86b54b43355e246466620 Mon Sep 17 00:00:00 2001 From: Ycblue Date: Tue, 14 Feb 2023 21:31:47 +0100 Subject: [PATCH 4/6] Implemented transform. Passed code style tests. Signed-off-by: Ycblue --- .../apps/pathology/transforms/stain/array.py | 22 +- .../pathology/transforms/stain/dictionary.py | 5 +- tests/test_pathology_hedjitter.py | 221 ++++++++++++++++++ 3 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 tests/test_pathology_hedjitter.py diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 28f9ac1c8e..f35c04a74e 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -9,15 +9,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numbers from typing import Union import numpy as np -from skimage import color -from monai.utils import optional_import - -skimage = optional_import('skimage') from monai.transforms.transform import Transform +from monai.utils import optional_import + +color, _ = optional_import("skimage", name="color") class HEDJitter(Transform): @@ -33,8 +33,10 @@ class HEDJitter(Transform): Note: For more information refer to: - - the paper benchmarking different stain augmentation and normalization methods including HED: Tellez et al. 2020 https://arxiv.org/abs/1902.06543 - - the original paper on color deconvolution: Ruifrok et al. 2001 https://helios2.mi.parisdescartes.fr/~lomn/Cours/CV/BME/HistoPatho/Color/PythonColorDeconv/Quantification_of_histochemical_staining.pdf + - the paper benchmarking different stain augmentation and normalization methods including HED: + Tellez et al. 2020 https://arxiv.org/abs/1902.06543 + - the original paper on color deconvolution: Ruifrok et al. 2001 + https://helios2.mi.parisdescartes.fr/~lomn/Cours/CV/BME/HistoPatho/Color/PythonColorDeconv/Quantification_of_histochemical_staining.pdf - previous Pytorch implementation: https://github.com/gatsby2016/Augmentation-PyTorch-Transforms """ @@ -45,12 +47,12 @@ def __init__(self, theta: float = 0.0) -> None: self.alpha = np.random.uniform(1 - theta, 1 + theta, (1, 3)) self.betti = np.random.uniform(-theta, theta, (1, 3)) - def adjust_HED(img, alpha, betti): + def adjust_hed(self, img, alpha, betti): # img = np.array(img) - s = np.reshape(skimage.color.rgb2hed(img), (-1, 3)) + s = np.reshape(color.rgb2hed(img), (-1, 3)) ns = alpha * s + betti # perturbations on HED color space - nimg = skimage.color.hed2rgb(np.reshape(ns, img.shape)) + nimg = color.hed2rgb(np.reshape(ns, img.shape)) imin = nimg.min() imax = nimg.max() @@ -68,7 +70,7 @@ def __call__(self, image: np.ndarray) -> np.ndarray: """ if not isinstance(image, np.ndarray): raise TypeError("Image must be of type numpy.ndarray.") - perturbed_image = self.adjust_HED(image, self.alpha, self.betti) + perturbed_image = self.adjust_hed(image, self.alpha, self.betti) return perturbed_image def __repr__(self): diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index 0b366ac294..06a1682d00 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -15,6 +15,7 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ +import numbers from typing import Dict, Hashable, Mapping, Union import numpy as np @@ -26,7 +27,7 @@ class HEDJitterd(MapTransform): - + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.HEDJitter`. Class to randomly perturbe HED color space values of image using stain deconvolution. @@ -39,7 +40,7 @@ class HEDJitterd(MapTransform): """ - def __init__(self, keys: KeysCollection, theta: float = 0.0) -> None: + def __init__(self, keys: KeysCollection, theta: float = 0.0, allow_missing_keys: bool = False) -> None: super().__init__(keys, allow_missing_keys) assert isinstance(theta, numbers.Number), "theta should be a single number." self.hedjitter = HEDJitter(theta=theta) diff --git a/tests/test_pathology_hedjitter.py b/tests/test_pathology_hedjitter.py new file mode 100644 index 0000000000..2115ce9a99 --- /dev/null +++ b/tests/test_pathology_hedjitter.py @@ -0,0 +1,221 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.pathology.transforms import ExtractHEStainsD, NormalizeHEStainsD + +# None inputs +EXTRACT_STAINS_TEST_CASE_0 = (None,) +EXTRACT_STAINS_TEST_CASE_00 = (None, None) +NORMALIZE_STAINS_TEST_CASE_0 = (None,) +NORMALIZE_STAINS_TEST_CASE_00: tuple = ({}, None, None) + +# input pixels all transparent and below the beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled, but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_2 = [np.full((3, 2, 3), 100)] + +# input pixels uniformly filled (different value), but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_3 = [np.full((3, 2, 3), 150)] + +# input pixels uniformly filled with zeros, leading to two identical stains extracted +EXTRACT_STAINS_TEST_CASE_4 = [ + np.zeros((3, 2, 3)), + np.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), +] + +# input pixels not uniformly filled, leading to two different stains extracted +EXTRACT_STAINS_TEST_CASE_5 = [ + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), +] + +# input pixels all transparent and below the beta absorbance threshold +NORMALIZE_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled with zeros, and target stain matrix provided +NORMALIZE_STAINS_TEST_CASE_2 = [{"target_he": np.full((3, 2), 1)}, np.zeros((3, 2, 3)), np.full((3, 2, 3), 11)] + +# input pixels uniformly filled with zeros, and target stain matrix not provided +NORMALIZE_STAINS_TEST_CASE_3 = [ + {}, + np.zeros((3, 2, 3)), + np.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), +] + +# input pixels not uniformly filled +NORMALIZE_STAINS_TEST_CASE_4 = [ + {"target_he": np.full((3, 2), 1)}, + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), +] + + +class TestExtractHEStainsD(unittest.TestCase): + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test HE stain extraction on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + with self.assertRaises(ValueError): + ExtractHEStainsD([key])({key: image}) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) + def test_identical_result_vectors(self, image): + """ + Test HE stain extraction on input images that are + uniformly filled with pixels that have absorbance above the + beta absorbance threshold. Since input image is uniformly filled, + the two extracted stains should have the same RGB values. So, + we assert that the first column is equal to the second column + of the returned stain matrix. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + result = ExtractHEStainsD([key])({key: image}) + np.testing.assert_array_equal(result[key][:, 0], result[key][:, 1]) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_00, EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) + def test_result_value(self, image, expected_data): + """ + Test that an input image returns an expected stain matrix. + + For test case 4: + - a uniformly filled input image should result in + eigenvectors [[1,0,0],[0,1,0],[0,0,1]] + - phi should be an array containing only values of + arctan(1) since the ratio between the eigenvectors + corresponding to the two largest eigenvalues is 1 + - maximum phi and minimum phi should thus be arctan(1) + - thus, maximum vector and minimum vector should be + [[0],[0.70710677],[0.70710677]] + - the resulting extracted stain should be + [[0,0],[0.70710678,0.70710678],[0.70710678,0.70710678]] + + For test case 5: + - the non-uniformly filled input image should result in + eigenvectors [[0,0,1],[1,0,0],[0,1,0]] + - maximum phi and minimum phi should thus be 0.785 and + 0.188 respectively + - thus, maximum vector and minimum vector should be + [[0.18696113],[0],[0.98236734]] and + [[0.70710677],[0],[0.70710677]] respectively + - the resulting extracted stain should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]] + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + result = ExtractHEStainsD([key])({key: image}) + np.testing.assert_allclose(result[key], expected_data) + + +class TestNormalizeHEStainsD(unittest.TestCase): + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_0, NORMALIZE_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test HE stain normalization on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStainsD([key])({key: image}) + else: + with self.assertRaises(ValueError): + NormalizeHEStainsD([key])({key: image}) + + @parameterized.expand( + [ + NORMALIZE_STAINS_TEST_CASE_00, + NORMALIZE_STAINS_TEST_CASE_2, + NORMALIZE_STAINS_TEST_CASE_3, + NORMALIZE_STAINS_TEST_CASE_4, + ] + ) + def test_result_value(self, arguments, image, expected_data): + """ + Test that an input image returns an expected normalized image. + + For test case 2: + - This case tests calling the stain normalizer, after the + _deconvolution_extract_conc function. This is because the normalized + concentration returned for each pixel is the same as the reference + maximum stain concentrations in the case that the image is uniformly + filled, as in this test case. This is because the maximum concentration + for each stain is the same as each pixel's concentration. + - Thus, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be a matrix of + dims (3, 2, 3), with all values 11. + + For test case 3: + - This case also tests calling the stain normalizer, after the + _deconvolution_extract_conc function returns the image concentration + matrix. + - As in test case 2, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target default stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be [[[63, 25, 60], [63, 25, 60]], + [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]] + + For test case 4: + - For this non-uniformly filled image, the stain extracted should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]], as validated for the + ExtractHEStains class. Solving the linear least squares problem (since + absorbance matrix = stain matrix * concentration matrix), we obtain the concentration + matrix that should be [[-0.3101, 7.7508, 7.7508, 7.7508, 7.7508, 7.7508], + [5.8022, 0, 0, 0, 0, 0]] + - Normalizing the concentration matrix, taking the matrix product of the + target stain matrix and the concentration matrix, using the inverse + Beer-Lambert transform to obtain the RGB image from the absorbance + image, and finally converting to uint8, we get that the stain normalized + image should be [[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], + [[33, 33, 33], [33, 33, 33]]] + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStainsD([key])({key: image}) + else: + result = NormalizeHEStainsD([key], **arguments)({key: image}) + np.testing.assert_allclose(result[key], expected_data) + + +if __name__ == "__main__": + unittest.main() From e6afaf42dc50bbb9dc0f75936a0806d1d969dab2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 16:29:25 +0000 Subject: [PATCH 5/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/apps/pathology/transforms/stain/array.py | 7 +++---- monai/apps/pathology/transforms/stain/dictionary.py | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index f5ecfdb211..8ce2ed6fb8 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -12,7 +12,6 @@ from __future__ import annotations import numbers -from typing import Union import numpy as np @@ -77,9 +76,9 @@ def __call__(self, image: np.ndarray) -> np.ndarray: def __repr__(self): format_string = self.__class__.__name__ + "(" - format_string += "theta={0}".format(self.theta) - format_string += ",alpha={0}".format(self.alpha) - format_string += ",betti={0}".format(self.betti) + format_string += f"theta={self.theta}" + format_string += f",alpha={self.alpha}" + format_string += f",betti={self.betti}" return format_string diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index 4030f531b6..41e12419bd 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -19,7 +19,6 @@ import numbers from collections.abc import Hashable, Mapping -from typing import Dict, Union import numpy as np @@ -48,7 +47,7 @@ def __init__(self, keys: KeysCollection, theta: float = 0.0, allow_missing_keys: assert isinstance(theta, numbers.Number), "theta should be a single number." self.hedjitter = HEDJitter(theta=theta) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.ndarray]: d = dict(data) for key in self.key_iterator(d): d[key] = self.hedjitter(d[key]) From 1cd30dd8c8cfe8f7ff7e3a30201ffc2d213bf928 Mon Sep 17 00:00:00 2001 From: Ycblue Date: Wed, 29 Mar 2023 10:35:41 +0200 Subject: [PATCH 6/6] passed codeformat Signed-off-by: Ycblue --- monai/apps/pathology/transforms/stain/array.py | 6 ------ monai/apps/pathology/transforms/stain/dictionary.py | 5 ----- tests/test_pathology_hedjitter.py | 2 ++ 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 009560071b..8ce2ed6fb8 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -9,16 +9,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -<<<<<<< HEAD -import numbers -from typing import Union - -======= from __future__ import annotations import numbers ->>>>>>> 606a10339d0f932b8844ca87271294f7c52f7dec import numpy as np from monai.transforms.transform import Transform diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index 55f5e6e864..41e12419bd 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -15,15 +15,10 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ -<<<<<<< HEAD -import numbers -from typing import Dict, Hashable, Mapping, Union -======= from __future__ import annotations import numbers from collections.abc import Hashable, Mapping ->>>>>>> 606a10339d0f932b8844ca87271294f7c52f7dec import numpy as np diff --git a/tests/test_pathology_hedjitter.py b/tests/test_pathology_hedjitter.py index 2115ce9a99..07db1c3e48 100644 --- a/tests/test_pathology_hedjitter.py +++ b/tests/test_pathology_hedjitter.py @@ -9,6 +9,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import unittest import numpy as np