From 3e2de7136e9f8e27cf127ec964c6e0c14fe915fd Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 12 Apr 2021 18:17:57 -0700 Subject: [PATCH 01/21] added stain norm and tests Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 193 +++++++++++++++++++++++++++++ tests/test_pathology_transforms.py | 74 +++++++++++ 2 files changed, 267 insertions(+) create mode 100644 monai/apps/pathology/transforms.py create mode 100644 tests/test_pathology_transforms.py diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py new file mode 100644 index 0000000000..1c631c0be2 --- /dev/null +++ b/monai/apps/pathology/transforms.py @@ -0,0 +1,193 @@ +# modified from sources: +# - Original implementation from Macenko paper in Matlab: https://github.com/mitkovetta/staining-normalization +# - Implementation in Python: https://github.com/schaugf/HEnorm_python +# - Link to Macenko et al., 2009 paper: http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf + +from typing import TYPE_CHECKING + +from monai.transforms.transform import Transform +from monai.utils import exact_version, optional_import + +if TYPE_CHECKING: + import cupy as cp +else: + cp, _ = optional_import("cupy", "8.6.0", exact_version) + + +class ExtractStainsMacenko(Transform): + """Class to extract a target stain from an image, using the Macenko method for stain deconvolution. + + Args: + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) + and pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15 + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to None. + """ + + def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_cref: cp.ndarray = None) -> None: + self.tli = tli + self.alpha = alpha + self.beta = beta + + self.max_cref = max_cref + if self.max_cref is None: + self.max_cref = cp.array([1.9705, 1.0308]) + + def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: + """Perform Stain Deconvolution using the Macenko Method, and return stain matrix for the image. + + Args: + img: RGB image to perform stain deconvolution of + + Return: + he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) + """ + # reshape image + img = img.reshape((-1, 3)) + + # calculate absorbance + absorbance = -cp.log(cp.clip(img.astype(cp.float) + 1, a_max=self.tli) / self.tli) + + # remove transparent pixels + absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] + + # compute eigenvectors + _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T)) + + # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues + t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) + + # find the min and max vectors and project back to absorbance space + phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) + min_phi = cp.percentile(phi, self.alpha) + max_phi = cp.percentile(phi, 100 - self.alpha) + v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))]).T) + v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))]).T) + + # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second + if v_min[0] > v_max[0]: + he = cp.array((v_min[:, 0], v_max[:, 0])).T + else: + he = cp.array((v_max[:, 0], v_min[:, 0])).T + + return he + + def __call__(self, image: cp.ndarray) -> cp.ndarray: + """Perform stain extraction. + + Args: + image: RGB image to extract stain from + + return: + target_he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) + """ + target_he = self._deconvolution_extract_stain(image) + return target_he + + +class NormalizeStainsMacenko(Transform): + """Class to normalize patches/images to a reference or target image stain, using the Macenko method. + + Performs stain deconvolution of the source image to obtain the stain concentration matrix + for the image. Then, performs the inverse Beer-Lambert transform to recreate the + patch using the target H&E stain matrix provided. If no target stain provided, a default + reference stain is used. Similarly, if no maximum stain concentrations are provided, a + reference maximum stain concentrations matrix is used. + + Args: + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) and + pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15. + target_he: target stain matrix. Defaults to None. + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to None. + """ + + def __init__( + self, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + target_he: cp.ndarray = None, + max_cref: cp.ndarray = None, + ) -> None: + self.tli = tli + self.alpha = alpha + self.beta = beta + + self.target_he = target_he + if self.target_he is None: + self.target_he = cp.array([[0.5626, 0.2159], [0.7201, 0.8012], [0.4062, 0.5581]]) + + self.max_cref = max_cref + if self.max_cref is None: + self.max_cref = cp.array([1.9705, 1.0308]) + + def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: + """Perform Stain Deconvolution using the Macenko Method, and return stain concentration. + + Args: + img: RGB image to perform stain deconvolution of + + Return: + conc_norm: stain concentration matrix for the input image + """ + # reshape image + img = img.reshape((-1, 3)) + + # calculate absorbance + absorbance = -cp.log(cp.clip(img.astype(cp.float) + 1, a_max=self.tli) / self.tli) + + # remove transparent pixels + absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] + + # compute eigenvectors + _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T)) + + # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues + t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) + + # find the min and max vectors and project back to absorbance space + phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) + min_phi = cp.percentile(phi, self.alpha) + max_phi = cp.percentile(phi, 100 - self.alpha) + v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))]).T) + v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))]).T) + + # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second + if v_min[0] > v_max[0]: + he = cp.array((v_min[:, 0], v_max[:, 0])).T + else: + he = cp.array((v_max[:, 0], v_min[:, 0])).T + + # rows correspond to channels (RGB), columns to absorbance values + y = cp.reshape(absorbance, (-1, 3)).T + + # determine concentrations of the individual stains + conc = cp.linalg.lstsq(he, y, rcond=None)[0] + + # normalize stain concentrations + max_conc = cp.array([cp.percentile(conc[0, :], 99), cp.percentile(conc[1, :], 99)]) + tmp = cp.divide(max_conc, self.max_cref) + conc_norm = cp.divide(conc, tmp[:, cp.newaxis]) + return conc_norm + + def __call__(self, image: cp.ndarray) -> cp.ndarray: + """Perform stain normalization. + + Args: + image: RGB image/patch to stain normalize + + Return: + image_norm: stain normalized image/patch + """ + h, w, _ = image.shape + image_c = self._deconvolution_extract_conc(image) + + image_norm = cp.multiply(self.tli, cp.exp(-self.target_he.dot(image_c))) + image_norm[image_norm > 255] = 254 + image_norm = cp.reshape(image_norm.T, (h, w, 3)).astype(cp.uint8) + return image_norm diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py new file mode 100644 index 0000000000..eabcc3d9e1 --- /dev/null +++ b/tests/test_pathology_transforms.py @@ -0,0 +1,74 @@ +# Copyright 2020 - 2021 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 +from typing import TYPE_CHECKING + +from parameterized import parameterized + +from monai.apps.pathology.transforms import ExtractStainsMacenko +from monai.utils import exact_version, optional_import + +if TYPE_CHECKING: + import cupy as cp +else: + cp, _ = optional_import("cupy", "8.6.0", exact_version) + +# input pixels are all transparent and below the beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_1 = [ + cp.zeros((3, 2, 3)), + cp.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), +] + +# input pixels are all the same, but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_2 = [ + cp.full((3, 2, 3), 200), + cp.array([[0.57735027, 0.57735027], [0.57735027, 0.57735027], [0.57735027, 0.57735027]]), +] + +# input pixels are all transparent and below the beta absorbance threshold +NORMALIZE_STAINS_TEST_CASE_1 = [ + {}, + cp.zeros((3, 2, 3)), + cp.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), +] + +# input pixels are all the same, but above beta absorbance threshold +NORMALIZE_STAINS_TEST_CASE_2 = [ + {}, + cp.full((3, 2, 3), 200), + cp.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), +] + +# with a custom target_he, which is the same as the image's stain matrix +NORMALIZE_STAINS_TEST_CASE_3 = [ + {"target_he": cp.full((3, 2), 0.57735027)}, + cp.full((3, 2, 3), 200), + cp.full((3, 2, 3), 42), +] + + +class TestExtractStainsMacenko(unittest.TestCase): + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1, EXTRACT_STAINS_TEST_CASE_2]) + def test_value(self, image, expected_data): + result = ExtractStainsMacenko()(image) + cp.testing.assert_allclose(result, expected_data) + + +class TestNormalizeStainsMacenko(unittest.TestCase): + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1, NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3]) + def test_value(self, argments, image, expected_data): + result = NormalizeStainsMacenko(**argments)(image) + cp.testing.assert_allclose(result, expected_data) + + +if __name__ == "__main__": + unittest.main() From e197f9d31cdbe0c029aa2659b14da989384fde2f Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Thu, 15 Apr 2021 11:32:16 -0700 Subject: [PATCH 02/21] import changes Signed-off-by: Neha Srivathsa --- tests/test_pathology_transforms.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index eabcc3d9e1..ea0138a488 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -10,17 +10,13 @@ # limitations under the License. import unittest -from typing import TYPE_CHECKING from parameterized import parameterized from monai.apps.pathology.transforms import ExtractStainsMacenko from monai.utils import exact_version, optional_import -if TYPE_CHECKING: - import cupy as cp -else: - cp, _ = optional_import("cupy", "8.6.0", exact_version) +cp, has_cp = optional_import("cupy", "8.6.0", exact_version) # input pixels are all transparent and below the beta absorbance threshold EXTRACT_STAINS_TEST_CASE_1 = [ @@ -58,6 +54,7 @@ class TestExtractStainsMacenko(unittest.TestCase): @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1, EXTRACT_STAINS_TEST_CASE_2]) + @unittests.skipUnless(has_cp, "Requires CuPy") def test_value(self, image, expected_data): result = ExtractStainsMacenko()(image) cp.testing.assert_allclose(result, expected_data) @@ -65,6 +62,7 @@ def test_value(self, image, expected_data): class TestNormalizeStainsMacenko(unittest.TestCase): @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1, NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3]) + @unittests.skipUnless(has_cp, "Requires CuPy") def test_value(self, argments, image, expected_data): result = NormalizeStainsMacenko(**argments)(image) cp.testing.assert_allclose(result, expected_data) From ad957fa0308baeee6fbd6fb0079601634f68d147 Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 19 Apr 2021 11:01:49 -0700 Subject: [PATCH 03/21] changed stain extraction tests Signed-off-by: Neha Srivathsa --- tests/test_pathology_transforms.py | 86 ++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index ea0138a488..63af5bd10d 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -18,16 +18,31 @@ cp, has_cp = optional_import("cupy", "8.6.0", exact_version) -# input pixels are all transparent and below the beta absorbance threshold +# input pixels all transparent and below the beta absorbance threshold EXTRACT_STAINS_TEST_CASE_1 = [ + cp.full((3, 2, 3), 240), +] + +# input pixels uniformly filled, but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_2 = [ + cp.full((3, 2, 3), 100), +] + +# input pixels uniformly filled (different value), but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_3 = [ + cp.full((3, 2, 3), 150), +] + +# input pixels uniformly filled with zeros, leading to two identical stains extracted +EXTRACT_STAINS_TEST_CASE_4 = [ cp.zeros((3, 2, 3)), cp.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), ] -# input pixels are all the same, but above beta absorbance threshold -EXTRACT_STAINS_TEST_CASE_2 = [ - cp.full((3, 2, 3), 200), - cp.array([[0.57735027, 0.57735027], [0.57735027, 0.57735027], [0.57735027, 0.57735027]]), +# input pixels not uniformly filled, leading to two different stains extracted +EXTRACT_STAINS_TEST_CASE_5 = [ + cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + cp.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), ] # input pixels are all transparent and below the beta absorbance threshold @@ -53,16 +68,69 @@ class TestExtractStainsMacenko(unittest.TestCase): - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1, EXTRACT_STAINS_TEST_CASE_2]) - @unittests.skipUnless(has_cp, "Requires CuPy") - def test_value(self, image, expected_data): + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) + @unittest.skipUnless(has_cp, "Requires CuPy") + def test_transparent_image(self, image): + """ + Test Macenko 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. + """ + with self.assertRaises(ValueError): + ExtractStainsMacenko()(image) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) + @unittest.skipUnless(has_cp, "Requires CuPy") + def test_identical_result_vectors(self, image): + """ + Test Macenko 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. + """ + result = ExtractStainsMacenko()(image) + cp.testing.assert_array_equal(result[:, 0], result[:, 1]) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) + @unittest.skipUnless(has_cp, "Requires CuPy") + 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]] + """ result = ExtractStainsMacenko()(image) cp.testing.assert_allclose(result, expected_data) class TestNormalizeStainsMacenko(unittest.TestCase): @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1, NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3]) - @unittests.skipUnless(has_cp, "Requires CuPy") + @unittest.skipUnless(has_cp, "Requires CuPy") def test_value(self, argments, image, expected_data): result = NormalizeStainsMacenko(**argments)(image) cp.testing.assert_allclose(result, expected_data) From 00518fa98deb545d36ea50c9b8a11ab2e33e264e Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 19 Apr 2021 15:16:57 -0700 Subject: [PATCH 04/21] edited stain norm tests Signed-off-by: Neha Srivathsa --- tests/test_pathology_transforms.py | 88 +++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 63af5bd10d..99f8bf6111 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -45,25 +45,30 @@ cp.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), ] -# input pixels are all transparent and below the beta absorbance threshold +# input pixels all transparent and below the beta absorbance threshold NORMALIZE_STAINS_TEST_CASE_1 = [ - {}, - cp.zeros((3, 2, 3)), - cp.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), + cp.full((3, 2, 3), 240), ] -# input pixels are all the same, but above beta absorbance threshold +# input pixels uniformly filled with zeros, and target stain matrix provided NORMALIZE_STAINS_TEST_CASE_2 = [ + {"target_he": cp.full((3, 2), 1)}, + cp.zeros((3, 2, 3)), + cp.full((3, 2, 3), 11), +] + +# input pixels uniformly filled with zeros, and target stain matrix not provided +NORMALIZE_STAINS_TEST_CASE_3 = [ {}, - cp.full((3, 2, 3), 200), + cp.zeros((3, 2, 3)), cp.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), ] -# with a custom target_he, which is the same as the image's stain matrix -NORMALIZE_STAINS_TEST_CASE_3 = [ - {"target_he": cp.full((3, 2), 0.57735027)}, - cp.full((3, 2, 3), 200), - cp.full((3, 2, 3), 42), +# input pixels not uniformly filled +NORMALIZE_STAINS_TEST_CASE_4 = [ + {"target_he": cp.full((3, 2), 1)}, + cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + cp.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), ] @@ -129,9 +134,66 @@ def test_result_value(self, image, expected_data): class TestNormalizeStainsMacenko(unittest.TestCase): - @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1, NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3]) + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1]) + @unittest.skipUnless(has_cp, "Requires CuPy") + def test_transparent_image(self, image): + """ + Test Macenko 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. + """ + with self.assertRaises(ValueError): + NormalizeStainsMacenko()(image) + + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3, NORMALIZE_STAINS_TEST_CASE_4]) @unittest.skipUnless(has_cp, "Requires CuPy") - def test_value(self, argments, image, expected_data): + def test_result_value(self, argments, 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 + ExtractStainsMacenko 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]]] + """ result = NormalizeStainsMacenko(**argments)(image) cp.testing.assert_allclose(result, expected_data) From 45a2ac57ab376fd026044c9b1de5d4d683f62a31 Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 19 Apr 2021 15:23:46 -0700 Subject: [PATCH 05/21] convert floats to float32 Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 32 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index 1c631c0be2..f3666b8664 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -48,13 +48,13 @@ def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: img = img.reshape((-1, 3)) # calculate absorbance - absorbance = -cp.log(cp.clip(img.astype(cp.float) + 1, a_max=self.tli) / self.tli) + absorbance = -cp.log(cp.clip(img.astype(cp.float32) + 1, a_max=self.tli) / self.tli) # remove transparent pixels absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] # compute eigenvectors - _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T)) + _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T).astype(cp.float32)) # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) @@ -63,14 +63,14 @@ def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) min_phi = cp.percentile(phi, self.alpha) max_phi = cp.percentile(phi, 100 - self.alpha) - v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))]).T) - v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))]).T) + v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))], dtype=cp.float32).T) + v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))], dtype=cp.float32).T) # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second if v_min[0] > v_max[0]: - he = cp.array((v_min[:, 0], v_max[:, 0])).T + he = cp.array((v_min[:, 0], v_max[:, 0]), dtype=cp.float32).T else: - he = cp.array((v_max[:, 0], v_min[:, 0])).T + he = cp.array((v_max[:, 0], v_min[:, 0]), dtype=cp.float32).T return he @@ -139,13 +139,13 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: img = img.reshape((-1, 3)) # calculate absorbance - absorbance = -cp.log(cp.clip(img.astype(cp.float) + 1, a_max=self.tli) / self.tli) + absorbance = -cp.log(cp.clip(img.astype(cp.float32) + 1, a_max=self.tli) / self.tli) # remove transparent pixels absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] # compute eigenvectors - _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T)) + _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T).astype(cp.float32)) # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) @@ -154,14 +154,14 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) min_phi = cp.percentile(phi, self.alpha) max_phi = cp.percentile(phi, 100 - self.alpha) - v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))]).T) - v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))]).T) + v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))], dtype=cp.float32).T) + v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))], dtype=cp.float32).T) # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second if v_min[0] > v_max[0]: - he = cp.array((v_min[:, 0], v_max[:, 0])).T + he = cp.array((v_min[:, 0], v_max[:, 0]), dtype=cp.float32).T else: - he = cp.array((v_max[:, 0], v_min[:, 0])).T + he = cp.array((v_max[:, 0], v_min[:, 0]), dtype=cp.float32).T # rows correspond to channels (RGB), columns to absorbance values y = cp.reshape(absorbance, (-1, 3)).T @@ -170,9 +170,9 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: conc = cp.linalg.lstsq(he, y, rcond=None)[0] # normalize stain concentrations - max_conc = cp.array([cp.percentile(conc[0, :], 99), cp.percentile(conc[1, :], 99)]) - tmp = cp.divide(max_conc, self.max_cref) - conc_norm = cp.divide(conc, tmp[:, cp.newaxis]) + max_conc = cp.array([cp.percentile(conc[0, :], 99), cp.percentile(conc[1, :], 99)], dtype=cp.float32) + tmp = cp.divide(max_conc, self.max_cref, dtype=cp.float32) + conc_norm = cp.divide(conc, tmp[:, cp.newaxis], dtype=cp.float32) return conc_norm def __call__(self, image: cp.ndarray) -> cp.ndarray: @@ -187,7 +187,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: h, w, _ = image.shape image_c = self._deconvolution_extract_conc(image) - image_norm = cp.multiply(self.tli, cp.exp(-self.target_he.dot(image_c))) + image_norm = cp.multiply(self.tli, cp.exp(-self.target_he.dot(image_c)), dtype=cp.float32) image_norm[image_norm > 255] = 254 image_norm = cp.reshape(image_norm.T, (h, w, 3)).astype(cp.uint8) return image_norm From 4b0f6967d0b6fe3bdf91e88526a71bd7886c86f6 Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 19 Apr 2021 15:26:23 -0700 Subject: [PATCH 06/21] added uint8 assumption to docstring Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index f3666b8664..2688eab698 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -39,7 +39,7 @@ def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: """Perform Stain Deconvolution using the Macenko Method, and return stain matrix for the image. Args: - img: RGB image to perform stain deconvolution of + img: uint8 RGB image to perform stain deconvolution of Return: he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) @@ -78,7 +78,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: """Perform stain extraction. Args: - image: RGB image to extract stain from + image: uint8 RGB image to extract stain from return: target_he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) @@ -130,7 +130,7 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: """Perform Stain Deconvolution using the Macenko Method, and return stain concentration. Args: - img: RGB image to perform stain deconvolution of + img: uint8 RGB image to perform stain deconvolution of Return: conc_norm: stain concentration matrix for the input image @@ -179,7 +179,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: """Perform stain normalization. Args: - image: RGB image/patch to stain normalize + image: uint8 RGB image/patch to stain normalize Return: image_norm: stain normalized image/patch From cb99aef7669e75b7aa4ab95f5a75ffed556cd85c Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 19 Apr 2021 16:31:35 -0700 Subject: [PATCH 07/21] add error case Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index 2688eab698..b23b27a817 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -52,6 +52,8 @@ def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: # remove transparent pixels absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] + if len(absorbance_hat) == 0: + raise ValueError(f"All pixels of the input image are below the absorbance threshold.") # compute eigenvectors _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T).astype(cp.float32)) @@ -143,6 +145,8 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: # remove transparent pixels absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] + if len(absorbance_hat) == 0: + raise ValueError(f"All pixels of the input image are below the absorbance threshold.") # compute eigenvectors _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T).astype(cp.float32)) From 3fcc3666aa7ead00b9c04d99591752a89cf7d9dc Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 19 Apr 2021 17:38:12 -0700 Subject: [PATCH 08/21] formatting change Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index b23b27a817..b1f8822d03 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -53,7 +53,7 @@ def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: # remove transparent pixels absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] if len(absorbance_hat) == 0: - raise ValueError(f"All pixels of the input image are below the absorbance threshold.") + raise ValueError("All pixels of the input image are below the absorbance threshold.") # compute eigenvectors _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T).astype(cp.float32)) @@ -146,7 +146,7 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: # remove transparent pixels absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] if len(absorbance_hat) == 0: - raise ValueError(f"All pixels of the input image are below the absorbance threshold.") + raise ValueError("All pixels of the input image are below the absorbance threshold.") # compute eigenvectors _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T).astype(cp.float32)) From fa95e0dde5ff20367d17396e754642613a5203f9 Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 26 Apr 2021 08:51:25 -0700 Subject: [PATCH 09/21] modify tests wrt cupy import Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 6 + tests/test_pathology_transforms.py | 179 ++++++++++++++++++----------- 2 files changed, 118 insertions(+), 67 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index b1f8822d03..433aac44da 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -85,6 +85,9 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: return: target_he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) """ + if not isinstance(image, cp.ndarray): + raise TypeError("Image must be of type cupy.ndarray.") + target_he = self._deconvolution_extract_stain(image) return target_he @@ -188,6 +191,9 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: Return: image_norm: stain normalized image/patch """ + if not isinstance(image, cp.ndarray): + raise TypeError("Image must be of type cupy.ndarray.") + h, w, _ = image.shape image_c = self._deconvolution_extract_conc(image) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 99f8bf6111..de21e138a0 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -18,63 +18,88 @@ cp, has_cp = optional_import("cupy", "8.6.0", exact_version) -# input pixels all transparent and below the beta absorbance threshold -EXTRACT_STAINS_TEST_CASE_1 = [ - cp.full((3, 2, 3), 240), -] - -# input pixels uniformly filled, but above beta absorbance threshold -EXTRACT_STAINS_TEST_CASE_2 = [ - cp.full((3, 2, 3), 100), -] - -# input pixels uniformly filled (different value), but above beta absorbance threshold -EXTRACT_STAINS_TEST_CASE_3 = [ - cp.full((3, 2, 3), 150), -] - -# input pixels uniformly filled with zeros, leading to two identical stains extracted -EXTRACT_STAINS_TEST_CASE_4 = [ - cp.zeros((3, 2, 3)), - cp.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 = [ - cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), - cp.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 = [ - cp.full((3, 2, 3), 240), -] - -# input pixels uniformly filled with zeros, and target stain matrix provided -NORMALIZE_STAINS_TEST_CASE_2 = [ - {"target_he": cp.full((3, 2), 1)}, - cp.zeros((3, 2, 3)), - cp.full((3, 2, 3), 11), -] - -# input pixels uniformly filled with zeros, and target stain matrix not provided -NORMALIZE_STAINS_TEST_CASE_3 = [ - {}, - cp.zeros((3, 2, 3)), - cp.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": cp.full((3, 2), 1)}, - cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), - cp.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), -] + +EXTRACT_STAINS_TEST_CASE_1 = (None,) +EXTRACT_STAINS_TEST_CASE_2 = (None,) +EXTRACT_STAINS_TEST_CASE_3 = (None,) +EXTRACT_STAINS_TEST_CASE_4 = (None, None) +EXTRACT_STAINS_TEST_CASE_5 = (None, None) +NORMALIZE_STAINS_TEST_CASE_1 = (None,) +NORMALIZE_STAINS_TEST_CASE_2 = ({}, None, None) +NORMALIZE_STAINS_TEST_CASE_3 = ({}, None, None) +NORMALIZE_STAINS_TEST_CASE_4 = ({}, None, None) + + +def prepare_test_data(): + # input pixels all transparent and below the beta absorbance threshold + global EXTRACT_STAINS_TEST_CASE_1 + EXTRACT_STAINS_TEST_CASE_1 = [ + cp.full((3, 2, 3), 240), + ] + + # input pixels uniformly filled, but above beta absorbance threshold + global EXTRACT_STAINS_TEST_CASE_2 + EXTRACT_STAINS_TEST_CASE_2 = [ + cp.full((3, 2, 3), 100), + ] + + # input pixels uniformly filled (different value), but above beta absorbance threshold + global EXTRACT_STAINS_TEST_CASE_3 + EXTRACT_STAINS_TEST_CASE_3 = [ + cp.full((3, 2, 3), 150), + ] + + # input pixels uniformly filled with zeros, leading to two identical stains extracted + global EXTRACT_STAINS_TEST_CASE_4 + EXTRACT_STAINS_TEST_CASE_4 = [ + cp.zeros((3, 2, 3)), + cp.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), + ] + + # input pixels not uniformly filled, leading to two different stains extracted + global EXTRACT_STAINS_TEST_CASE_5 + EXTRACT_STAINS_TEST_CASE_5 = [ + cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + cp.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), + ] + + # input pixels all transparent and below the beta absorbance threshold + global NORMALIZE_STAINS_TEST_CASE_1 + NORMALIZE_STAINS_TEST_CASE_1 = [ + cp.full((3, 2, 3), 240), + ] + + # input pixels uniformly filled with zeros, and target stain matrix provided + global NORMALIZE_STAINS_TEST_CASE_2 + NORMALIZE_STAINS_TEST_CASE_2 = [ + {"target_he": cp.full((3, 2), 1)}, + cp.zeros((3, 2, 3)), + cp.full((3, 2, 3), 11), + ] + + # input pixels uniformly filled with zeros, and target stain matrix not provided + global NORMALIZE_STAINS_TEST_CASE_3 + NORMALIZE_STAINS_TEST_CASE_3 = [ + {}, + cp.zeros((3, 2, 3)), + cp.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), + ] + + # input pixels not uniformly filled + global NORMALIZE_STAINS_TEST_CASE_4 + NORMALIZE_STAINS_TEST_CASE_4 = [ + {"target_he": cp.full((3, 2), 1)}, + cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + cp.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), + ] class TestExtractStainsMacenko(unittest.TestCase): - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) @unittest.skipUnless(has_cp, "Requires CuPy") + def setUp(self): + prepare_test_data() + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) def test_transparent_image(self, image): """ Test Macenko stain extraction on an image that comprises @@ -83,11 +108,14 @@ def test_transparent_image(self, image): since once the transparent pixels are removed, there are no remaining pixels to compute eigenvectors. """ - with self.assertRaises(ValueError): - ExtractStainsMacenko()(image) + if image == None: + with self.assertRaises(TypeError): + NormalizeStainsMacenko()(image) + else: + with self.assertRaises(ValueError): + ExtractStainsMacenko()(image) @parameterized.expand([EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) - @unittest.skipUnless(has_cp, "Requires CuPy") def test_identical_result_vectors(self, image): """ Test Macenko stain extraction on input images that are @@ -97,11 +125,14 @@ def test_identical_result_vectors(self, image): we assert that the first column is equal to the second column of the returned stain matrix. """ - result = ExtractStainsMacenko()(image) - cp.testing.assert_array_equal(result[:, 0], result[:, 1]) + if image == None: + with self.assertRaises(TypeError): + NormalizeStainsMacenko()(image) + else: + result = ExtractStainsMacenko()(image) + cp.testing.assert_array_equal(result[:, 0], result[:, 1]) @parameterized.expand([EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) - @unittest.skipUnless(has_cp, "Requires CuPy") def test_result_value(self, image, expected_data): """ Test that an input image returns an expected stain matrix. @@ -129,13 +160,20 @@ def test_result_value(self, image, expected_data): - the resulting extracted stain should be [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]] """ - result = ExtractStainsMacenko()(image) - cp.testing.assert_allclose(result, expected_data) + if image == None: + with self.assertRaises(TypeError): + NormalizeStainsMacenko()(image) + else: + result = ExtractStainsMacenko()(image) + cp.testing.assert_allclose(result, expected_data) class TestNormalizeStainsMacenko(unittest.TestCase): - @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1]) @unittest.skipUnless(has_cp, "Requires CuPy") + def setUp(self): + prepare_test_data() + + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1]) def test_transparent_image(self, image): """ Test Macenko stain normalization on an image that comprises @@ -144,11 +182,14 @@ def test_transparent_image(self, image): since once the transparent pixels are removed, there are no remaining pixels to compute eigenvectors. """ - with self.assertRaises(ValueError): - NormalizeStainsMacenko()(image) + if image == None: + with self.assertRaises(TypeError): + NormalizeStainsMacenko()(image) + else: + with self.assertRaises(ValueError): + NormalizeStainsMacenko()(image) @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3, NORMALIZE_STAINS_TEST_CASE_4]) - @unittest.skipUnless(has_cp, "Requires CuPy") def test_result_value(self, argments, image, expected_data): """ Test that an input image returns an expected normalized image. @@ -194,8 +235,12 @@ def test_result_value(self, argments, image, expected_data): image should be [[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]] """ - result = NormalizeStainsMacenko(**argments)(image) - cp.testing.assert_allclose(result, expected_data) + if image == None: + with self.assertRaises(TypeError): + NormalizeStainsMacenko()(image) + else: + result = NormalizeStainsMacenko(**argments)(image) + cp.testing.assert_allclose(result, expected_data) if __name__ == "__main__": From f3b290983bdf65b8c240cdb6a30d94e6fc1ef14c Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 26 Apr 2021 13:24:10 -0700 Subject: [PATCH 10/21] minor change to pass lint test Signed-off-by: Neha Srivathsa --- tests/test_pathology_transforms.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index de21e138a0..1c0d141d7a 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -108,7 +108,7 @@ def test_transparent_image(self, image): since once the transparent pixels are removed, there are no remaining pixels to compute eigenvectors. """ - if image == None: + if image is None: with self.assertRaises(TypeError): NormalizeStainsMacenko()(image) else: @@ -125,7 +125,7 @@ def test_identical_result_vectors(self, image): we assert that the first column is equal to the second column of the returned stain matrix. """ - if image == None: + if image is None: with self.assertRaises(TypeError): NormalizeStainsMacenko()(image) else: @@ -160,7 +160,7 @@ def test_result_value(self, image, expected_data): - the resulting extracted stain should be [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]] """ - if image == None: + if image is None: with self.assertRaises(TypeError): NormalizeStainsMacenko()(image) else: @@ -182,7 +182,7 @@ def test_transparent_image(self, image): since once the transparent pixels are removed, there are no remaining pixels to compute eigenvectors. """ - if image == None: + if image is None: with self.assertRaises(TypeError): NormalizeStainsMacenko()(image) else: @@ -235,7 +235,7 @@ def test_result_value(self, argments, image, expected_data): image should be [[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]] """ - if image == None: + if image is None: with self.assertRaises(TypeError): NormalizeStainsMacenko()(image) else: From b80fc17109434ca39577a84b6c150fa5cb55529b Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 26 Apr 2021 13:45:11 -0700 Subject: [PATCH 11/21] import changes Signed-off-by: Neha Srivathsa --- tests/test_pathology_transforms.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 1c0d141d7a..87f52b94e1 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -13,11 +13,13 @@ from parameterized import parameterized -from monai.apps.pathology.transforms import ExtractStainsMacenko from monai.utils import exact_version, optional_import cp, has_cp = optional_import("cupy", "8.6.0", exact_version) +if has_cp: + from monai.apps.pathology.transforms import ExtractStainsMacenko, NormalizeStainsMacenko + EXTRACT_STAINS_TEST_CASE_1 = (None,) EXTRACT_STAINS_TEST_CASE_2 = (None,) @@ -25,9 +27,9 @@ EXTRACT_STAINS_TEST_CASE_4 = (None, None) EXTRACT_STAINS_TEST_CASE_5 = (None, None) NORMALIZE_STAINS_TEST_CASE_1 = (None,) -NORMALIZE_STAINS_TEST_CASE_2 = ({}, None, None) -NORMALIZE_STAINS_TEST_CASE_3 = ({}, None, None) -NORMALIZE_STAINS_TEST_CASE_4 = ({}, None, None) +NORMALIZE_STAINS_TEST_CASE_2 = (None, None, None) +NORMALIZE_STAINS_TEST_CASE_3 = (None, None, None) +NORMALIZE_STAINS_TEST_CASE_4 = (None, None, None) def prepare_test_data(): From 05ec7868c4a58c9394824efe1c47c1eda6549034 Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Wed, 28 Apr 2021 14:34:32 -0700 Subject: [PATCH 12/21] refactored classes Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 64 ++++++++---------------------- 1 file changed, 16 insertions(+), 48 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index 433aac44da..fcbd57c5bf 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -95,7 +95,8 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: class NormalizeStainsMacenko(Transform): """Class to normalize patches/images to a reference or target image stain, using the Macenko method. - Performs stain deconvolution of the source image to obtain the stain concentration matrix + Performs stain deconvolution of the source image using the ExtractStainsMacenko + class, to obtain the stain matrix and calculate the stain concentration matrix for the image. Then, performs the inverse Beer-Lambert transform to recreate the patch using the target H&E stain matrix provided. If no target stain provided, a default reference stain is used. Similarly, if no maximum stain concentrations are provided, a @@ -131,44 +132,27 @@ def __init__( if self.max_cref is None: self.max_cref = cp.array([1.9705, 1.0308]) - def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: - """Perform Stain Deconvolution using the Macenko Method, and return stain concentration. + def __call__(self, image: cp.ndarray) -> cp.ndarray: + """Perform stain normalization. Args: - img: uint8 RGB image to perform stain deconvolution of + image: uint8 RGB image/patch to stain normalize Return: - conc_norm: stain concentration matrix for the input image + image_norm: stain normalized image/patch """ - # reshape image - img = img.reshape((-1, 3)) - - # calculate absorbance - absorbance = -cp.log(cp.clip(img.astype(cp.float32) + 1, a_max=self.tli) / self.tli) - - # remove transparent pixels - absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] - if len(absorbance_hat) == 0: - raise ValueError("All pixels of the input image are below the absorbance threshold.") - - # compute eigenvectors - _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T).astype(cp.float32)) + if not isinstance(image, cp.ndarray): + raise TypeError("Image must be of type cupy.ndarray.") - # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues - t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) + # extract stain of the image + stain_extractor = ExtractStainsMacenko(tli=self.tli, alpha=self.alpha, beta=self.beta, max_cref=self.max_cref) + he = stain_extractor(image) - # find the min and max vectors and project back to absorbance space - phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) - min_phi = cp.percentile(phi, self.alpha) - max_phi = cp.percentile(phi, 100 - self.alpha) - v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))], dtype=cp.float32).T) - v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))], dtype=cp.float32).T) + h, w, _ = image.shape - # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second - if v_min[0] > v_max[0]: - he = cp.array((v_min[:, 0], v_max[:, 0]), dtype=cp.float32).T - else: - he = cp.array((v_max[:, 0], v_min[:, 0]), dtype=cp.float32).T + # reshape image and calculate absorbance + image = image.reshape((-1, 3)) + absorbance = -cp.log(cp.clip(image.astype(cp.float32) + 1, a_max=self.tli) / self.tli) # rows correspond to channels (RGB), columns to absorbance values y = cp.reshape(absorbance, (-1, 3)).T @@ -179,23 +163,7 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: # normalize stain concentrations max_conc = cp.array([cp.percentile(conc[0, :], 99), cp.percentile(conc[1, :], 99)], dtype=cp.float32) tmp = cp.divide(max_conc, self.max_cref, dtype=cp.float32) - conc_norm = cp.divide(conc, tmp[:, cp.newaxis], dtype=cp.float32) - return conc_norm - - def __call__(self, image: cp.ndarray) -> cp.ndarray: - """Perform stain normalization. - - Args: - image: uint8 RGB image/patch to stain normalize - - Return: - image_norm: stain normalized image/patch - """ - if not isinstance(image, cp.ndarray): - raise TypeError("Image must be of type cupy.ndarray.") - - h, w, _ = image.shape - image_c = self._deconvolution_extract_conc(image) + image_c = cp.divide(conc, tmp[:, cp.newaxis], dtype=cp.float32) image_norm = cp.multiply(self.tli, cp.exp(-self.target_he.dot(image_c)), dtype=cp.float32) image_norm[image_norm > 255] = 254 From bcb2211fcce9a792c8e4dc4552c93ba5a1cbe628 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Thu, 29 Apr 2021 15:02:24 -0400 Subject: [PATCH 13/21] Restructure and rename transforms Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/__init__.py | 1 + monai/apps/pathology/transforms/__init__.py | 10 ++++++ .../{transforms.py => transforms/array.py} | 35 +++++++++++++++---- monai/apps/pathology/transforms/dictionary.py | 16 +++++++++ tests/test_pathology_transforms.py | 14 ++++---- 5 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 monai/apps/pathology/transforms/__init__.py rename monai/apps/pathology/{transforms.py => transforms/array.py} (82%) create mode 100644 monai/apps/pathology/transforms/dictionary.py diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index 203e1a80d7..48a132d940 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -12,4 +12,5 @@ from .datasets import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCacheDataset from .handlers import ProbMapProducer from .metrics import LesionFROC +from .transforms.array import ExtractHEStains, NormalizeStainsMacenko from .utils import PathologyProbNMS, compute_isolated_tumor_cells, compute_multi_instance_mask diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py new file mode 100644 index 0000000000..14ae193634 --- /dev/null +++ b/monai/apps/pathology/transforms/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2020 - 2021 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. diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms/array.py similarity index 82% rename from monai/apps/pathology/transforms.py rename to monai/apps/pathology/transforms/array.py index fcbd57c5bf..da1c79ef31 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms/array.py @@ -1,7 +1,14 @@ -# modified from sources: -# - Original implementation from Macenko paper in Matlab: https://github.com/mitkovetta/staining-normalization -# - Implementation in Python: https://github.com/schaugf/HEnorm_python -# - Link to Macenko et al., 2009 paper: http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf +# Copyright 2020 - 2021 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. + from typing import TYPE_CHECKING @@ -14,7 +21,7 @@ cp, _ = optional_import("cupy", "8.6.0", exact_version) -class ExtractStainsMacenko(Transform): +class ExtractHEStains(Transform): """Class to extract a target stain from an image, using the Macenko method for stain deconvolution. Args: @@ -24,6 +31,13 @@ class ExtractStainsMacenko(Transform): beta: absorbance threshold for transparent pixels. Defaults to 0.15 max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). Defaults to None. + + Note: + For more information refer to: + - the original paper: Macenko et al., 2009 http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf + - the previous implementations: + - MATLAB: https://github.com/mitkovetta/staining-normalization + - Python: https://github.com/schaugf/HEnorm_python """ def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_cref: cp.ndarray = None) -> None: @@ -95,7 +109,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: class NormalizeStainsMacenko(Transform): """Class to normalize patches/images to a reference or target image stain, using the Macenko method. - Performs stain deconvolution of the source image using the ExtractStainsMacenko + Performs stain deconvolution of the source image using the ExtractHEStains class, to obtain the stain matrix and calculate the stain concentration matrix for the image. Then, performs the inverse Beer-Lambert transform to recreate the patch using the target H&E stain matrix provided. If no target stain provided, a default @@ -110,6 +124,13 @@ class NormalizeStainsMacenko(Transform): target_he: target stain matrix. Defaults to None. max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). Defaults to None. + + Note: + For more information refer to: + - the original paper: Macenko et al., 2009 http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf + - the previous implementations: + - MATLAB: https://github.com/mitkovetta/staining-normalization + - Python: https://github.com/schaugf/HEnorm_python """ def __init__( @@ -145,7 +166,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: raise TypeError("Image must be of type cupy.ndarray.") # extract stain of the image - stain_extractor = ExtractStainsMacenko(tli=self.tli, alpha=self.alpha, beta=self.beta, max_cref=self.max_cref) + stain_extractor = ExtractHEStains(tli=self.tli, alpha=self.alpha, beta=self.beta, max_cref=self.max_cref) he = stain_extractor(image) h, w, _ = image.shape diff --git a/monai/apps/pathology/transforms/dictionary.py b/monai/apps/pathology/transforms/dictionary.py new file mode 100644 index 0000000000..786c9f94a1 --- /dev/null +++ b/monai/apps/pathology/transforms/dictionary.py @@ -0,0 +1,16 @@ +# Copyright 2020 - 2021 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. +""" +A collection of dictionary-based wrappers around the pathology transforms +defined in :py:class:`monai.apps.pathology.transforms.array`. + +Class names are ended with 'd' to denote dictionary-based transforms. +""" diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 87f52b94e1..67e2d8ca56 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -13,13 +13,11 @@ from parameterized import parameterized +from monai.apps.pathology.transforms.array import ExtractHEStains, NormalizeStainsMacenko from monai.utils import exact_version, optional_import cp, has_cp = optional_import("cupy", "8.6.0", exact_version) -if has_cp: - from monai.apps.pathology.transforms import ExtractStainsMacenko, NormalizeStainsMacenko - EXTRACT_STAINS_TEST_CASE_1 = (None,) EXTRACT_STAINS_TEST_CASE_2 = (None,) @@ -96,7 +94,7 @@ def prepare_test_data(): ] -class TestExtractStainsMacenko(unittest.TestCase): +class TestExtractHEStains(unittest.TestCase): @unittest.skipUnless(has_cp, "Requires CuPy") def setUp(self): prepare_test_data() @@ -115,7 +113,7 @@ def test_transparent_image(self, image): NormalizeStainsMacenko()(image) else: with self.assertRaises(ValueError): - ExtractStainsMacenko()(image) + ExtractHEStains()(image) @parameterized.expand([EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) def test_identical_result_vectors(self, image): @@ -131,7 +129,7 @@ def test_identical_result_vectors(self, image): with self.assertRaises(TypeError): NormalizeStainsMacenko()(image) else: - result = ExtractStainsMacenko()(image) + result = ExtractHEStains()(image) cp.testing.assert_array_equal(result[:, 0], result[:, 1]) @parameterized.expand([EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) @@ -166,7 +164,7 @@ def test_result_value(self, image, expected_data): with self.assertRaises(TypeError): NormalizeStainsMacenko()(image) else: - result = ExtractStainsMacenko()(image) + result = ExtractHEStains()(image) cp.testing.assert_allclose(result, expected_data) @@ -226,7 +224,7 @@ def test_result_value(self, argments, image, expected_data): 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 - ExtractStainsMacenko class. Solving the linear least squares problem (since + 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]] From 283444ece4046b17fd5067e78a785a982937e0a1 Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Fri, 30 Apr 2021 15:51:44 -0700 Subject: [PATCH 14/21] added dict transform Signed-off-by: Neha Srivathsa --- monai/apps/pathology/__init__.py | 1 + monai/apps/pathology/transforms/dictionary.py | 108 ++++++++ tests/test_pathology_transforms.py | 6 +- tests/test_pathology_transformsd.py | 252 ++++++++++++++++++ 4 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 tests/test_pathology_transformsd.py diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index 48a132d940..9d60decd9c 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -13,4 +13,5 @@ from .handlers import ProbMapProducer from .metrics import LesionFROC from .transforms.array import ExtractHEStains, NormalizeStainsMacenko +from .transforms.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod from .utils import PathologyProbNMS, compute_isolated_tumor_cells, compute_multi_instance_mask diff --git a/monai/apps/pathology/transforms/dictionary.py b/monai/apps/pathology/transforms/dictionary.py index 786c9f94a1..e04cd88a2a 100644 --- a/monai/apps/pathology/transforms/dictionary.py +++ b/monai/apps/pathology/transforms/dictionary.py @@ -14,3 +14,111 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ + +from typing import TYPE_CHECKING, Dict, Hashable, Mapping + +from monai.apps.pathology.transforms.array import ExtractHEStains, NormalizeStainsMacenko +from monai.config import KeysCollection +from monai.transforms.transform import MapTransform +from monai.utils import exact_version, optional_import + +if TYPE_CHECKING: + import cupy as cp +else: + cp, _ = optional_import("cupy", "8.6.0", exact_version) + + +class ExtractHEStainsd(MapTransform): + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.ExtractHEStains`. + Class to extract a target stain from an image, using the Macenko method for stain deconvolution. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) + and pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15 + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to None. + allow_missing_keys: don't raise exception if key is missing. + + Note: + For more information refer to: + - the original paper: Macenko et al., 2009 http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf + - the previous implementations: + - MATLAB: https://github.com/mitkovetta/staining-normalization + - Python: https://github.com/schaugf/HEnorm_python + """ + + def __init__( + self, + keys: KeysCollection, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + max_cref: cp.ndarray = None, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.extractor = ExtractHEStains(tli=tli, alpha=alpha, beta=beta, max_cref=max_cref) + + def __call__(self, data: Mapping[Hashable, cp.ndarray]) -> Dict[Hashable, cp.ndarray]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.extractor(d[key]) + return d + + +class NormalizeStainsMacenkod(MapTransform): + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.NormalizeStainsMacenko`. + + Class to normalize patches/images to a reference or target image stain, using the Macenko method. + + Performs stain deconvolution of the source image using the ExtractHEStains + class, to obtain the stain matrix and calculate the stain concentration matrix + for the image. Then, performs the inverse Beer-Lambert transform to recreate the + patch using the target H&E stain matrix provided. If no target stain provided, a default + reference stain is used. Similarly, if no maximum stain concentrations are provided, a + reference maximum stain concentrations matrix is used. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) and + pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15. + target_he: target stain matrix. Defaults to None. + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to None. + allow_missing_keys: don't raise exception if key is missing. + + Note: + For more information refer to: + - the original paper: Macenko et al., 2009 http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf + - the previous implementations: + - MATLAB: https://github.com/mitkovetta/staining-normalization + - Python: https://github.com/schaugf/HEnorm_python + """ + + def __init__( + self, + keys: KeysCollection, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + target_he: cp.ndarray = None, + max_cref: cp.ndarray = None, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.normalizer = NormalizeStainsMacenko( + tli=tli, alpha=alpha, beta=beta, target_he=target_he, max_cref=max_cref + ) + + def __call__(self, data: Mapping[Hashable, cp.ndarray]) -> Dict[Hashable, cp.ndarray]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.normalizer(d[key]) + return d diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 67e2d8ca56..386506672f 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -110,7 +110,7 @@ def test_transparent_image(self, image): """ if image is None: with self.assertRaises(TypeError): - NormalizeStainsMacenko()(image) + ExtractHEStains()(image) else: with self.assertRaises(ValueError): ExtractHEStains()(image) @@ -127,7 +127,7 @@ def test_identical_result_vectors(self, image): """ if image is None: with self.assertRaises(TypeError): - NormalizeStainsMacenko()(image) + ExtractHEStains()(image) else: result = ExtractHEStains()(image) cp.testing.assert_array_equal(result[:, 0], result[:, 1]) @@ -162,7 +162,7 @@ def test_result_value(self, image, expected_data): """ if image is None: with self.assertRaises(TypeError): - NormalizeStainsMacenko()(image) + ExtractHEStains()(image) else: result = ExtractHEStains()(image) cp.testing.assert_allclose(result, expected_data) diff --git a/tests/test_pathology_transformsd.py b/tests/test_pathology_transformsd.py new file mode 100644 index 0000000000..f6fda7f1ad --- /dev/null +++ b/tests/test_pathology_transformsd.py @@ -0,0 +1,252 @@ +# Copyright 2020 - 2021 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 + +from parameterized import parameterized + +from monai.apps.pathology.transforms.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod +from monai.utils import exact_version, optional_import + +cp, has_cp = optional_import("cupy", "8.6.0", exact_version) + + +EXTRACT_STAINS_TEST_CASE_1 = (None,) +EXTRACT_STAINS_TEST_CASE_2 = (None,) +EXTRACT_STAINS_TEST_CASE_3 = (None,) +EXTRACT_STAINS_TEST_CASE_4 = (None, None) +EXTRACT_STAINS_TEST_CASE_5 = (None, None) +NORMALIZE_STAINS_TEST_CASE_1 = (None,) +NORMALIZE_STAINS_TEST_CASE_2 = (None, None, None) +NORMALIZE_STAINS_TEST_CASE_3 = (None, None, None) +NORMALIZE_STAINS_TEST_CASE_4 = (None, None, None) + + +def prepare_test_data(): + # input pixels all transparent and below the beta absorbance threshold + global EXTRACT_STAINS_TEST_CASE_1 + EXTRACT_STAINS_TEST_CASE_1 = [ + cp.full((3, 2, 3), 240), + ] + + # input pixels uniformly filled, but above beta absorbance threshold + global EXTRACT_STAINS_TEST_CASE_2 + EXTRACT_STAINS_TEST_CASE_2 = [ + cp.full((3, 2, 3), 100), + ] + + # input pixels uniformly filled (different value), but above beta absorbance threshold + global EXTRACT_STAINS_TEST_CASE_3 + EXTRACT_STAINS_TEST_CASE_3 = [ + cp.full((3, 2, 3), 150), + ] + + # input pixels uniformly filled with zeros, leading to two identical stains extracted + global EXTRACT_STAINS_TEST_CASE_4 + EXTRACT_STAINS_TEST_CASE_4 = [ + cp.zeros((3, 2, 3)), + cp.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), + ] + + # input pixels not uniformly filled, leading to two different stains extracted + global EXTRACT_STAINS_TEST_CASE_5 + EXTRACT_STAINS_TEST_CASE_5 = [ + cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + cp.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), + ] + + # input pixels all transparent and below the beta absorbance threshold + global NORMALIZE_STAINS_TEST_CASE_1 + NORMALIZE_STAINS_TEST_CASE_1 = [ + cp.full((3, 2, 3), 240), + ] + + # input pixels uniformly filled with zeros, and target stain matrix provided + global NORMALIZE_STAINS_TEST_CASE_2 + NORMALIZE_STAINS_TEST_CASE_2 = [ + {"target_he": cp.full((3, 2), 1)}, + cp.zeros((3, 2, 3)), + cp.full((3, 2, 3), 11), + ] + + # input pixels uniformly filled with zeros, and target stain matrix not provided + global NORMALIZE_STAINS_TEST_CASE_3 + NORMALIZE_STAINS_TEST_CASE_3 = [ + {}, + cp.zeros((3, 2, 3)), + cp.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), + ] + + # input pixels not uniformly filled + global NORMALIZE_STAINS_TEST_CASE_4 + NORMALIZE_STAINS_TEST_CASE_4 = [ + {"target_he": cp.full((3, 2), 1)}, + cp.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + cp.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), + ] + + +class TestExtractHEStainsd(unittest.TestCase): + @unittest.skipUnless(has_cp, "Requires CuPy") + def setUp(self): + prepare_test_data() + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test Macenko 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_2, EXTRACT_STAINS_TEST_CASE_3]) + def test_identical_result_vectors(self, image): + """ + Test Macenko 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}) + cp.testing.assert_array_equal(result[key][:, 0], result[key][:, 1]) + + @parameterized.expand([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}) + cp.testing.assert_allclose(result[key], expected_data) + + +class TestNormalizeStainsMacenkod(unittest.TestCase): + @unittest.skipUnless(has_cp, "Requires CuPy") + def setUp(self): + prepare_test_data() + + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test Macenko 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): + NormalizeStainsMacenkod([key])({key: image}) + else: + with self.assertRaises(ValueError): + NormalizeStainsMacenkod([key])({key: image}) + + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3, NORMALIZE_STAINS_TEST_CASE_4]) + def test_result_value(self, argments, 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): + NormalizeStainsMacenkod([key])({key: image}) + else: + result = NormalizeStainsMacenkod([key], **argments)({key: image}) + cp.testing.assert_allclose(result[key], expected_data) + + +if __name__ == "__main__": + unittest.main() From 7440a5bf64e515542ed8193b45fc93bea460df40 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Fri, 30 Apr 2021 21:50:05 -0400 Subject: [PATCH 15/21] Move stain_extractor to init Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/array.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/monai/apps/pathology/transforms/array.py b/monai/apps/pathology/transforms/array.py index da1c79ef31..b2a1128f5f 100644 --- a/monai/apps/pathology/transforms/array.py +++ b/monai/apps/pathology/transforms/array.py @@ -142,8 +142,6 @@ def __init__( max_cref: cp.ndarray = None, ) -> None: self.tli = tli - self.alpha = alpha - self.beta = beta self.target_he = target_he if self.target_he is None: @@ -153,6 +151,8 @@ def __init__( if self.max_cref is None: self.max_cref = cp.array([1.9705, 1.0308]) + self.stain_extractor = ExtractHEStains(tli=self.tli, alpha=alpha, beta=beta, max_cref=self.max_cref) + def __call__(self, image: cp.ndarray) -> cp.ndarray: """Perform stain normalization. @@ -166,8 +166,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: raise TypeError("Image must be of type cupy.ndarray.") # extract stain of the image - stain_extractor = ExtractHEStains(tli=self.tli, alpha=self.alpha, beta=self.beta, max_cref=self.max_cref) - he = stain_extractor(image) + he = self.stain_extractor(image) h, w, _ = image.shape From f6e9b387827e77f4004dcbd80471b78d209ef798 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Sun, 2 May 2021 11:46:06 -0400 Subject: [PATCH 16/21] Exclude pathology transform tests from mini tests Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- tests/min_tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/min_tests.py b/tests/min_tests.py index 3b4af99b6c..0c29fdcbcc 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -82,6 +82,8 @@ def run_testsuit(): "test_parallel_execution", "test_persistentdataset", "test_cachentransdataset", + "test_pathology_transforms.py", + "test_pathology_transformsd.py", "test_pil_reader", "test_plot_2d_or_3d_image", "test_png_rw", From 72e044833453ec65fa7274bf51c2a99279b18c5a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 3 May 2021 11:43:45 -0400 Subject: [PATCH 17/21] Fix type checking for cupy ndarray Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/array.py | 18 ++++++++++-------- monai/apps/pathology/transforms/dictionary.py | 14 +++++++------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/monai/apps/pathology/transforms/array.py b/monai/apps/pathology/transforms/array.py index b2a1128f5f..688b538ccd 100644 --- a/monai/apps/pathology/transforms/array.py +++ b/monai/apps/pathology/transforms/array.py @@ -17,8 +17,10 @@ if TYPE_CHECKING: import cupy as cp + from cupy import ndarray as cp_ndarray else: cp, _ = optional_import("cupy", "8.6.0", exact_version) + cp_ndarray, _ = optional_import("cupy", "8.6.0", exact_version, name="ndarray") class ExtractHEStains(Transform): @@ -40,7 +42,7 @@ class ExtractHEStains(Transform): - Python: https://github.com/schaugf/HEnorm_python """ - def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_cref: cp.ndarray = None) -> None: + def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_cref: cp_ndarray = None) -> None: self.tli = tli self.alpha = alpha self.beta = beta @@ -49,7 +51,7 @@ def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_c if self.max_cref is None: self.max_cref = cp.array([1.9705, 1.0308]) - def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: + def _deconvolution_extract_stain(self, img: cp_ndarray) -> cp_ndarray: """Perform Stain Deconvolution using the Macenko Method, and return stain matrix for the image. Args: @@ -90,7 +92,7 @@ def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: return he - def __call__(self, image: cp.ndarray) -> cp.ndarray: + def __call__(self, image: cp_ndarray) -> cp_ndarray: """Perform stain extraction. Args: @@ -99,7 +101,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: return: target_he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) """ - if not isinstance(image, cp.ndarray): + if not isinstance(image, cp_ndarray): raise TypeError("Image must be of type cupy.ndarray.") target_he = self._deconvolution_extract_stain(image) @@ -138,8 +140,8 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - target_he: cp.ndarray = None, - max_cref: cp.ndarray = None, + target_he: cp_ndarray = None, + max_cref: cp_ndarray = None, ) -> None: self.tli = tli @@ -153,7 +155,7 @@ def __init__( self.stain_extractor = ExtractHEStains(tli=self.tli, alpha=alpha, beta=beta, max_cref=self.max_cref) - def __call__(self, image: cp.ndarray) -> cp.ndarray: + def __call__(self, image: cp_ndarray) -> cp_ndarray: """Perform stain normalization. Args: @@ -162,7 +164,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: Return: image_norm: stain normalized image/patch """ - if not isinstance(image, cp.ndarray): + if not isinstance(image, cp_ndarray): raise TypeError("Image must be of type cupy.ndarray.") # extract stain of the image diff --git a/monai/apps/pathology/transforms/dictionary.py b/monai/apps/pathology/transforms/dictionary.py index e04cd88a2a..9cffc1ea9e 100644 --- a/monai/apps/pathology/transforms/dictionary.py +++ b/monai/apps/pathology/transforms/dictionary.py @@ -23,9 +23,9 @@ from monai.utils import exact_version, optional_import if TYPE_CHECKING: - import cupy as cp + from cupy import ndarray as cp_ndarray else: - cp, _ = optional_import("cupy", "8.6.0", exact_version) + cp_ndarray, _ = optional_import("cupy", "8.6.0", exact_version, name="ndarray") class ExtractHEStainsd(MapTransform): @@ -57,13 +57,13 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - max_cref: cp.ndarray = None, + max_cref: cp_ndarray = None, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) self.extractor = ExtractHEStains(tli=tli, alpha=alpha, beta=beta, max_cref=max_cref) - def __call__(self, data: Mapping[Hashable, cp.ndarray]) -> Dict[Hashable, cp.ndarray]: + def __call__(self, data: Mapping[Hashable, cp_ndarray]) -> Dict[Hashable, cp_ndarray]: d = dict(data) for key in self.key_iterator(d): d[key] = self.extractor(d[key]) @@ -108,8 +108,8 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - target_he: cp.ndarray = None, - max_cref: cp.ndarray = None, + target_he: cp_ndarray = None, + max_cref: cp_ndarray = None, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) @@ -117,7 +117,7 @@ def __init__( tli=tli, alpha=alpha, beta=beta, target_he=target_he, max_cref=max_cref ) - def __call__(self, data: Mapping[Hashable, cp.ndarray]) -> Dict[Hashable, cp.ndarray]: + def __call__(self, data: Mapping[Hashable, cp_ndarray]) -> Dict[Hashable, cp_ndarray]: d = dict(data) for key in self.key_iterator(d): d[key] = self.normalizer(d[key]) From be88f7d5ecb81a1bb031cb49eb9b423b8e8e7980 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 3 May 2021 11:51:00 -0400 Subject: [PATCH 18/21] Include pathology transform tests Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- tests/min_tests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/min_tests.py b/tests/min_tests.py index 0c29fdcbcc..3b4af99b6c 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -82,8 +82,6 @@ def run_testsuit(): "test_parallel_execution", "test_persistentdataset", "test_cachentransdataset", - "test_pathology_transforms.py", - "test_pathology_transformsd.py", "test_pil_reader", "test_plot_2d_or_3d_image", "test_png_rw", From dd5d82e6aec1819b8d8524c25887c9cd5bec8eec Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 3 May 2021 15:43:44 -0400 Subject: [PATCH 19/21] Update to cupy 9.0.0 Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/array.py | 4 ++-- monai/apps/pathology/transforms/dictionary.py | 2 +- tests/test_pathology_transforms.py | 2 +- tests/test_pathology_transformsd.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/monai/apps/pathology/transforms/array.py b/monai/apps/pathology/transforms/array.py index 688b538ccd..cbe58604e0 100644 --- a/monai/apps/pathology/transforms/array.py +++ b/monai/apps/pathology/transforms/array.py @@ -19,8 +19,8 @@ import cupy as cp from cupy import ndarray as cp_ndarray else: - cp, _ = optional_import("cupy", "8.6.0", exact_version) - cp_ndarray, _ = optional_import("cupy", "8.6.0", exact_version, name="ndarray") + cp, _ = optional_import("cupy", "9.0.0", exact_version) + cp_ndarray, _ = optional_import("cupy", "9.0.0", exact_version, name="ndarray") class ExtractHEStains(Transform): diff --git a/monai/apps/pathology/transforms/dictionary.py b/monai/apps/pathology/transforms/dictionary.py index 9cffc1ea9e..a40574216c 100644 --- a/monai/apps/pathology/transforms/dictionary.py +++ b/monai/apps/pathology/transforms/dictionary.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: from cupy import ndarray as cp_ndarray else: - cp_ndarray, _ = optional_import("cupy", "8.6.0", exact_version, name="ndarray") + cp_ndarray, _ = optional_import("cupy", "9.0.0", exact_version, name="ndarray") class ExtractHEStainsd(MapTransform): diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 386506672f..71dff42b63 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -16,7 +16,7 @@ from monai.apps.pathology.transforms.array import ExtractHEStains, NormalizeStainsMacenko from monai.utils import exact_version, optional_import -cp, has_cp = optional_import("cupy", "8.6.0", exact_version) +cp, has_cp = optional_import("cupy", "9.0.0", exact_version) EXTRACT_STAINS_TEST_CASE_1 = (None,) diff --git a/tests/test_pathology_transformsd.py b/tests/test_pathology_transformsd.py index f6fda7f1ad..83bf523806 100644 --- a/tests/test_pathology_transformsd.py +++ b/tests/test_pathology_transformsd.py @@ -16,7 +16,7 @@ from monai.apps.pathology.transforms.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod from monai.utils import exact_version, optional_import -cp, has_cp = optional_import("cupy", "8.6.0", exact_version) +cp, has_cp = optional_import("cupy", "9.0.0", exact_version) EXTRACT_STAINS_TEST_CASE_1 = (None,) From 128f01d3e45987dca09f1075b7f6d9809a20c24a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 3 May 2021 17:23:05 -0400 Subject: [PATCH 20/21] Remove exact version for cupy Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/array.py | 6 +++--- monai/apps/pathology/transforms/dictionary.py | 4 ++-- tests/test_pathology_transforms.py | 4 ++-- tests/test_pathology_transformsd.py | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/monai/apps/pathology/transforms/array.py b/monai/apps/pathology/transforms/array.py index cbe58604e0..f2d9ce11de 100644 --- a/monai/apps/pathology/transforms/array.py +++ b/monai/apps/pathology/transforms/array.py @@ -13,14 +13,14 @@ from typing import TYPE_CHECKING from monai.transforms.transform import Transform -from monai.utils import exact_version, optional_import +from monai.utils import optional_import if TYPE_CHECKING: import cupy as cp from cupy import ndarray as cp_ndarray else: - cp, _ = optional_import("cupy", "9.0.0", exact_version) - cp_ndarray, _ = optional_import("cupy", "9.0.0", exact_version, name="ndarray") + cp, _ = optional_import("cupy") + cp_ndarray, _ = optional_import("cupy", name="ndarray") class ExtractHEStains(Transform): diff --git a/monai/apps/pathology/transforms/dictionary.py b/monai/apps/pathology/transforms/dictionary.py index a40574216c..35a9330539 100644 --- a/monai/apps/pathology/transforms/dictionary.py +++ b/monai/apps/pathology/transforms/dictionary.py @@ -20,12 +20,12 @@ from monai.apps.pathology.transforms.array import ExtractHEStains, NormalizeStainsMacenko from monai.config import KeysCollection from monai.transforms.transform import MapTransform -from monai.utils import exact_version, optional_import +from monai.utils import optional_import if TYPE_CHECKING: from cupy import ndarray as cp_ndarray else: - cp_ndarray, _ = optional_import("cupy", "9.0.0", exact_version, name="ndarray") + cp_ndarray, _ = optional_import("cupy", name="ndarray") class ExtractHEStainsd(MapTransform): diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 71dff42b63..c64538ae56 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -14,9 +14,9 @@ from parameterized import parameterized from monai.apps.pathology.transforms.array import ExtractHEStains, NormalizeStainsMacenko -from monai.utils import exact_version, optional_import +from monai.utils import optional_import -cp, has_cp = optional_import("cupy", "9.0.0", exact_version) +cp, has_cp = optional_import("cupy") EXTRACT_STAINS_TEST_CASE_1 = (None,) diff --git a/tests/test_pathology_transformsd.py b/tests/test_pathology_transformsd.py index 83bf523806..6657ca78f5 100644 --- a/tests/test_pathology_transformsd.py +++ b/tests/test_pathology_transformsd.py @@ -14,9 +14,9 @@ from parameterized import parameterized from monai.apps.pathology.transforms.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod -from monai.utils import exact_version, optional_import +from monai.utils import optional_import -cp, has_cp = optional_import("cupy", "9.0.0", exact_version) +cp, has_cp = optional_import("cupy") EXTRACT_STAINS_TEST_CASE_1 = (None,) From 0c7d3b01ee2b4855c68db3fd68435cf65e70e3ab Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 3 May 2021 15:03:01 -0700 Subject: [PATCH 21/21] add to docs Signed-off-by: Neha Srivathsa --- docs/source/apps.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 29d835514f..a045853428 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -87,3 +87,15 @@ Applications .. autofunction:: compute_isolated_tumor_cells .. autoclass:: PathologyProbNMS :members: + +.. automodule:: monai.apps.pathology.transforms.array +.. autoclass:: ExtractHEStains + :members: +.. autoclass:: NormalizeStainsMacenko + :members: + +.. automodule:: monai.apps.pathology.transforms.dictionary +.. autoclass:: ExtractHEStainsd + :members: +.. autoclass:: NormalizeStainsMacenkod + :members: