From 3e2de7136e9f8e27cf127ec964c6e0c14fe915fd Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Mon, 12 Apr 2021 18:17:57 -0700 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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: From d2d7a8a033239b4b1ce8d217c682535ac357f0c6 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 16:10:23 +0000 Subject: [PATCH 22/38] Organize into stain dir Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/{ => stain}/array.py | 0 monai/apps/pathology/transforms/{ => stain}/dictionary.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename monai/apps/pathology/transforms/{ => stain}/array.py (100%) rename monai/apps/pathology/transforms/{ => stain}/dictionary.py (100%) diff --git a/monai/apps/pathology/transforms/array.py b/monai/apps/pathology/transforms/stain/array.py similarity index 100% rename from monai/apps/pathology/transforms/array.py rename to monai/apps/pathology/transforms/stain/array.py diff --git a/monai/apps/pathology/transforms/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py similarity index 100% rename from monai/apps/pathology/transforms/dictionary.py rename to monai/apps/pathology/transforms/stain/dictionary.py From eb411d7f5dfb57ff7c4329217c3547e5c073c26a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 16:24:27 +0000 Subject: [PATCH 23/38] Add/update init files Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/__init__.py | 4 ++-- monai/apps/pathology/transforms/__init__.py | 3 +++ monai/apps/pathology/transforms/stain/__init__.py | 10 ++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 monai/apps/pathology/transforms/stain/__init__.py diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index 9d60decd9c..914cdf3116 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -12,6 +12,6 @@ from .datasets import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCacheDataset from .handlers import ProbMapProducer from .metrics import LesionFROC -from .transforms.array import ExtractHEStains, NormalizeStainsMacenko -from .transforms.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod +from .transforms import ExtractHEStains, NormalizeStainsMacenko +from .transforms import ExtractHEStainsd, NormalizeStainsMacenkod 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 index 14ae193634..bd3b779df4 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -8,3 +8,6 @@ # 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 .stain.array import ExtractHEStains, NormalizeStainsMacenko +from .stain.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod diff --git a/monai/apps/pathology/transforms/stain/__init__.py b/monai/apps/pathology/transforms/stain/__init__.py new file mode 100644 index 0000000000..14ae193634 --- /dev/null +++ b/monai/apps/pathology/transforms/stain/__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. From 9ddb4e10bf46828f0ab9d749e15b14f1af4dd1be Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 16:56:27 +0000 Subject: [PATCH 24/38] Transit all from cupy to numpy Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- .../apps/pathology/transforms/stain/array.py | 72 +++++++++---------- .../pathology/transforms/stain/dictionary.py | 20 +++--- 2 files changed, 40 insertions(+), 52 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index f2d9ce11de..c38af56bf6 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -10,17 +10,9 @@ # limitations under the License. -from typing import TYPE_CHECKING +import numpy as np from monai.transforms.transform import Transform -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") - cp_ndarray, _ = optional_import("cupy", name="ndarray") class ExtractHEStains(Transform): @@ -42,16 +34,16 @@ 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: np.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]) + self.max_cref = np.array([1.9705, 1.0308]) - def _deconvolution_extract_stain(self, img: cp_ndarray) -> cp_ndarray: + def _deconvolution_extract_stain(self, img: np.ndarray) -> np.ndarray: """Perform Stain Deconvolution using the Macenko Method, and return stain matrix for the image. Args: @@ -64,35 +56,35 @@ 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.float32) + 1, a_max=self.tli) / self.tli) + absorbance = -np.log(np.clip(img.astype(np.float32) + 1, a_max=self.tli) / self.tli) # remove transparent pixels - absorbance_hat = absorbance[cp.all(absorbance > self.beta, axis=1)] + absorbance_hat = absorbance[np.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)) + _, eigvecs = np.linalg.eigh(np.cov(absorbance_hat.T).astype(np.float32)) # 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))], dtype=cp.float32).T) - v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))], dtype=cp.float32).T) + phi = np.arctan2(t_hat[:, 1], t_hat[:, 0]) + min_phi = np.percentile(phi, self.alpha) + max_phi = np.percentile(phi, 100 - self.alpha) + v_min = eigvecs[:, 1:3].dot(np.array([(np.cos(min_phi), np.sin(min_phi))], dtype=np.float32).T) + v_max = eigvecs[:, 1:3].dot(np.array([(np.cos(max_phi), np.sin(max_phi))], dtype=np.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]), dtype=cp.float32).T + he = np.array((v_min[:, 0], v_max[:, 0]), dtype=np.float32).T else: - he = cp.array((v_max[:, 0], v_min[:, 0]), dtype=cp.float32).T + he = np.array((v_max[:, 0], v_min[:, 0]), dtype=np.float32).T return he - def __call__(self, image: cp_ndarray) -> cp_ndarray: + def __call__(self, image: np.ndarray) -> np.ndarray: """Perform stain extraction. Args: @@ -101,8 +93,8 @@ 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.") + if not isinstance(image, np.ndarray): + raise TypeError("Image must be of type numpy.ndarray.") target_he = self._deconvolution_extract_stain(image) return target_he @@ -140,22 +132,22 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - target_he: cp_ndarray = None, - max_cref: cp_ndarray = None, + target_he: np.ndarray = None, + max_cref: np.ndarray = None, ) -> None: self.tli = tli 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.target_he = np.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]) + self.max_cref = np.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: + def __call__(self, image: np.ndarray) -> np.ndarray: """Perform stain normalization. Args: @@ -164,8 +156,8 @@ 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.") + if not isinstance(image, np.ndarray): + raise TypeError("Image must be of type numpy.ndarray.") # extract stain of the image he = self.stain_extractor(image) @@ -174,20 +166,20 @@ def __call__(self, image: cp_ndarray) -> cp_ndarray: # 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) + absorbance = -np.log(np.clip(image.astype(np.float32) + 1, a_max=self.tli) / self.tli) # rows correspond to channels (RGB), columns to absorbance values - y = cp.reshape(absorbance, (-1, 3)).T + y = np.reshape(absorbance, (-1, 3)).T # determine concentrations of the individual stains - conc = cp.linalg.lstsq(he, y, rcond=None)[0] + conc = np.linalg.lstsq(he, y, rcond=None)[0] # 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) - image_c = cp.divide(conc, tmp[:, cp.newaxis], dtype=cp.float32) + max_conc = np.array([np.percentile(conc[0, :], 99), np.percentile(conc[1, :], 99)], dtype=np.float32) + tmp = np.divide(max_conc, self.max_cref, dtype=np.float32) + image_c = np.divide(conc, tmp[:, np.newaxis], dtype=np.float32) - image_norm = cp.multiply(self.tli, cp.exp(-self.target_he.dot(image_c)), dtype=cp.float32) + image_norm = np.multiply(self.tli, np.exp(-self.target_he.dot(image_c)), dtype=np.float32) image_norm[image_norm > 255] = 254 - image_norm = cp.reshape(image_norm.T, (h, w, 3)).astype(cp.uint8) + image_norm = np.reshape(image_norm.T, (h, w, 3)).astype(np.uint8) return image_norm diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index 35a9330539..46340e3419 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -17,15 +17,11 @@ from typing import TYPE_CHECKING, Dict, Hashable, Mapping -from monai.apps.pathology.transforms.array import ExtractHEStains, NormalizeStainsMacenko +import numpy as np + +from .array import ExtractHEStains, NormalizeStainsMacenko from monai.config import KeysCollection from monai.transforms.transform import MapTransform -from monai.utils import optional_import - -if TYPE_CHECKING: - from cupy import ndarray as cp_ndarray -else: - cp_ndarray, _ = optional_import("cupy", name="ndarray") class ExtractHEStainsd(MapTransform): @@ -57,13 +53,13 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - max_cref: cp_ndarray = None, + max_cref: np.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, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key in self.key_iterator(d): d[key] = self.extractor(d[key]) @@ -108,8 +104,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: np.ndarray = None, + max_cref: np.ndarray = None, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) @@ -117,7 +113,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, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key in self.key_iterator(d): d[key] = self.normalizer(d[key]) From 24cdf113fff5be9f67aea630a99c5c76f5db82cd Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 16:57:09 +0000 Subject: [PATCH 25/38] Update imports Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/__init__.py | 8 ++++++-- monai/apps/pathology/transforms/stain/__init__.py | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index 914cdf3116..a834de9d1c 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -12,6 +12,10 @@ from .datasets import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCacheDataset from .handlers import ProbMapProducer from .metrics import LesionFROC -from .transforms import ExtractHEStains, NormalizeStainsMacenko -from .transforms import ExtractHEStainsd, NormalizeStainsMacenkod +from .transforms.stain import ( + ExtractHEStains, + ExtractHEStainsd, + NormalizeStainsMacenko, + NormalizeStainsMacenkod, +) from .utils import PathologyProbNMS, compute_isolated_tumor_cells, compute_multi_instance_mask diff --git a/monai/apps/pathology/transforms/stain/__init__.py b/monai/apps/pathology/transforms/stain/__init__.py index 14ae193634..603a113ca3 100644 --- a/monai/apps/pathology/transforms/stain/__init__.py +++ b/monai/apps/pathology/transforms/stain/__init__.py @@ -8,3 +8,6 @@ # 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 .array import ExtractHEStains, NormalizeStainsMacenko +from .dictionary import ExtractHEStainsd, NormalizeStainsMacenkod From f26ef3aff13986b40af8dfb9e5f7634728982ab6 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 16:57:31 +0000 Subject: [PATCH 26/38] Update test cases for numpy Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- tests/test_pathology_transforms.py | 47 ++++++++++++++--------------- tests/test_pathology_transformsd.py | 2 +- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index c64538ae56..37202baf49 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -11,12 +11,11 @@ import unittest -from parameterized import parameterized +import numpy as np -from monai.apps.pathology.transforms.array import ExtractHEStains, NormalizeStainsMacenko -from monai.utils import optional_import +from parameterized import parameterized -cp, has_cp = optional_import("cupy") +from monai.apps.pathology.transforms import ExtractHEStains, NormalizeStainsMacenko EXTRACT_STAINS_TEST_CASE_1 = (None,) @@ -34,68 +33,67 @@ 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), + np.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), + np.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), + np.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]]), + 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 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]]), + 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 global NORMALIZE_STAINS_TEST_CASE_1 NORMALIZE_STAINS_TEST_CASE_1 = [ - cp.full((3, 2, 3), 240), + np.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), + {"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 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]]]), + 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 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]]]), + {"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 TestExtractHEStains(unittest.TestCase): - @unittest.skipUnless(has_cp, "Requires CuPy") def setUp(self): prepare_test_data() @@ -130,7 +128,7 @@ def test_identical_result_vectors(self, image): ExtractHEStains()(image) else: result = ExtractHEStains()(image) - cp.testing.assert_array_equal(result[:, 0], result[:, 1]) + np.testing.assert_array_equal(result[:, 0], result[:, 1]) @parameterized.expand([EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) def test_result_value(self, image, expected_data): @@ -165,11 +163,10 @@ def test_result_value(self, image, expected_data): ExtractHEStains()(image) else: result = ExtractHEStains()(image) - cp.testing.assert_allclose(result, expected_data) + np.testing.assert_allclose(result, expected_data) class TestNormalizeStainsMacenko(unittest.TestCase): - @unittest.skipUnless(has_cp, "Requires CuPy") def setUp(self): prepare_test_data() @@ -240,7 +237,7 @@ def test_result_value(self, argments, image, expected_data): NormalizeStainsMacenko()(image) else: result = NormalizeStainsMacenko(**argments)(image) - cp.testing.assert_allclose(result, expected_data) + np.testing.assert_allclose(result, expected_data) if __name__ == "__main__": diff --git a/tests/test_pathology_transformsd.py b/tests/test_pathology_transformsd.py index 6657ca78f5..d9c168b936 100644 --- a/tests/test_pathology_transformsd.py +++ b/tests/test_pathology_transformsd.py @@ -13,7 +13,7 @@ from parameterized import parameterized -from monai.apps.pathology.transforms.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod +from monai.apps.pathology.transforms import ExtractHEStainsd, NormalizeStainsMacenkod from monai.utils import optional_import cp, has_cp = optional_import("cupy") From e7ad5a5672d6177ff98b70d10cbe253b31da395f Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 17:19:04 +0000 Subject: [PATCH 27/38] Rename to NormalizeHEStains and NormalizeHEStainsD Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- docs/source/apps.rst | 10 +++--- monai/apps/pathology/__init__.py | 6 ++-- monai/apps/pathology/transforms/__init__.py | 4 +-- .../pathology/transforms/stain/__init__.py | 4 +-- .../apps/pathology/transforms/stain/array.py | 8 ++--- .../pathology/transforms/stain/dictionary.py | 16 ++++------ tests/test_pathology_transforms.py | 18 +++++------ tests/test_pathology_transformsd.py | 32 +++++++++---------- 8 files changed, 48 insertions(+), 50 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 180752e67c..6827e58056 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -99,14 +99,14 @@ Clara MMARs .. autoclass:: PathologyProbNMS :members: -.. automodule:: monai.apps.pathology.transforms.array +.. automodule:: monai.apps.pathology.transforms.stain.array .. autoclass:: ExtractHEStains :members: -.. autoclass:: NormalizeStainsMacenko +.. autoclass:: NormalizeHEStains :members: -.. automodule:: monai.apps.pathology.transforms.dictionary -.. autoclass:: ExtractHEStainsd +.. automodule:: monai.apps.pathology.transforms.stain.dictionary +.. autoclass:: ExtractHEStainsD :members: -.. autoclass:: NormalizeStainsMacenkod +.. autoclass:: NormalizeHEStainsD :members: diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index a834de9d1c..c67efdb823 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -14,8 +14,8 @@ from .metrics import LesionFROC from .transforms.stain import ( ExtractHEStains, - ExtractHEStainsd, - NormalizeStainsMacenko, - NormalizeStainsMacenkod, + ExtractHEStainsD, + NormalizeHEStains, + NormalizeHEStainsD, ) 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 index bd3b779df4..9e750482bf 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -9,5 +9,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .stain.array import ExtractHEStains, NormalizeStainsMacenko -from .stain.dictionary import ExtractHEStainsd, NormalizeStainsMacenkod +from .stain.array import ExtractHEStains, NormalizeHEStains +from .stain.dictionary import ExtractHEStainsD, NormalizeHEStainsD diff --git a/monai/apps/pathology/transforms/stain/__init__.py b/monai/apps/pathology/transforms/stain/__init__.py index 603a113ca3..4cf6dc2b6e 100644 --- a/monai/apps/pathology/transforms/stain/__init__.py +++ b/monai/apps/pathology/transforms/stain/__init__.py @@ -9,5 +9,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .array import ExtractHEStains, NormalizeStainsMacenko -from .dictionary import ExtractHEStainsd, NormalizeStainsMacenkod +from .array import ExtractHEStains, NormalizeHEStains +from .dictionary import ExtractHEStainsD, NormalizeHEStainsD diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index c38af56bf6..729d59f7d4 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -16,7 +16,7 @@ class ExtractHEStains(Transform): - """Class to extract a target stain from an image, using the Macenko method for stain deconvolution. + """Class to extract a target stain from an image, using stain deconvolution (see Note). Args: tli: transmitted light intensity. Defaults to 240. @@ -44,7 +44,7 @@ def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_c self.max_cref = np.array([1.9705, 1.0308]) def _deconvolution_extract_stain(self, img: np.ndarray) -> np.ndarray: - """Perform Stain Deconvolution using the Macenko Method, and return stain matrix for the image. + """Perform Stain Deconvolution and return stain matrix for the image. Args: img: uint8 RGB image to perform stain deconvolution of @@ -100,8 +100,8 @@ def __call__(self, image: np.ndarray) -> np.ndarray: return target_he -class NormalizeStainsMacenko(Transform): - """Class to normalize patches/images to a reference or target image stain, using the Macenko method. +class NormalizeHEStains(Transform): + """Class to normalize patches/images to a reference or target image stain (see Note). Performs stain deconvolution of the source image using the ExtractHEStains class, to obtain the stain matrix and calculate the stain concentration matrix diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index 46340e3419..1041764782 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -19,14 +19,14 @@ import numpy as np -from .array import ExtractHEStains, NormalizeStainsMacenko +from .array import ExtractHEStains, NormalizeHEStains from monai.config import KeysCollection from monai.transforms.transform import MapTransform -class ExtractHEStainsd(MapTransform): +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. + Class to extract a target stain from an image, using stain deconvolution. Args: keys: keys of the corresponding items to be transformed. @@ -66,10 +66,10 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class NormalizeStainsMacenkod(MapTransform): - """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.NormalizeStainsMacenko`. +class NormalizeHEStainsD(MapTransform): + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.NormalizeHEStains`. - Class to normalize patches/images to a reference or target image stain, using the Macenko method. + Class to normalize patches/images to a reference or target image stain (see Note). Performs stain deconvolution of the source image using the ExtractHEStains class, to obtain the stain matrix and calculate the stain concentration matrix @@ -109,9 +109,7 @@ def __init__( 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 - ) + self.normalizer = NormalizeHEStains(tli=tli, alpha=alpha, beta=beta, target_he=target_he, max_cref=max_cref) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_transforms.py index 37202baf49..bc268ef95c 100644 --- a/tests/test_pathology_transforms.py +++ b/tests/test_pathology_transforms.py @@ -15,7 +15,7 @@ from parameterized import parameterized -from monai.apps.pathology.transforms import ExtractHEStains, NormalizeStainsMacenko +from monai.apps.pathology.transforms import ExtractHEStains, NormalizeHEStains EXTRACT_STAINS_TEST_CASE_1 = (None,) @@ -100,7 +100,7 @@ def setUp(self): @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) def test_transparent_image(self, image): """ - Test Macenko stain extraction on an image that comprises + 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 @@ -116,7 +116,7 @@ def test_transparent_image(self, 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 + 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, @@ -166,14 +166,14 @@ def test_result_value(self, image, expected_data): np.testing.assert_allclose(result, expected_data) -class TestNormalizeStainsMacenko(unittest.TestCase): +class TestNormalizeHEStains(unittest.TestCase): 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 + 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 @@ -181,10 +181,10 @@ def test_transparent_image(self, image): """ if image is None: with self.assertRaises(TypeError): - NormalizeStainsMacenko()(image) + NormalizeHEStains()(image) else: with self.assertRaises(ValueError): - NormalizeStainsMacenko()(image) + NormalizeHEStains()(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): @@ -234,9 +234,9 @@ def test_result_value(self, argments, image, expected_data): """ if image is None: with self.assertRaises(TypeError): - NormalizeStainsMacenko()(image) + NormalizeHEStains()(image) else: - result = NormalizeStainsMacenko(**argments)(image) + result = NormalizeHEStains(**argments)(image) np.testing.assert_allclose(result, expected_data) diff --git a/tests/test_pathology_transformsd.py b/tests/test_pathology_transformsd.py index d9c168b936..b63f5cbbef 100644 --- a/tests/test_pathology_transformsd.py +++ b/tests/test_pathology_transformsd.py @@ -13,7 +13,7 @@ from parameterized import parameterized -from monai.apps.pathology.transforms import ExtractHEStainsd, NormalizeStainsMacenkod +from monai.apps.pathology.transforms import ExtractHEStainsD, NormalizeHEStainsD from monai.utils import optional_import cp, has_cp = optional_import("cupy") @@ -94,7 +94,7 @@ def prepare_test_data(): ] -class TestExtractHEStainsd(unittest.TestCase): +class TestExtractHEStainsD(unittest.TestCase): @unittest.skipUnless(has_cp, "Requires CuPy") def setUp(self): prepare_test_data() @@ -102,7 +102,7 @@ def setUp(self): @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) def test_transparent_image(self, image): """ - Test Macenko stain extraction on an image that comprises + 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 @@ -111,15 +111,15 @@ def test_transparent_image(self, image): key = "image" if image is None: with self.assertRaises(TypeError): - ExtractHEStainsd([key])({key: image}) + ExtractHEStainsD([key])({key: image}) else: with self.assertRaises(ValueError): - ExtractHEStainsd([key])({key: image}) + 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 + 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, @@ -129,9 +129,9 @@ def test_identical_result_vectors(self, image): key = "image" if image is None: with self.assertRaises(TypeError): - ExtractHEStainsd([key])({key: image}) + ExtractHEStainsD([key])({key: image}) else: - result = ExtractHEStainsd([key])({key: image}) + 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]) @@ -165,13 +165,13 @@ def test_result_value(self, image, expected_data): key = "image" if image is None: with self.assertRaises(TypeError): - ExtractHEStainsd([key])({key: image}) + ExtractHEStainsD([key])({key: image}) else: - result = ExtractHEStainsd([key])({key: image}) + result = ExtractHEStainsD([key])({key: image}) cp.testing.assert_allclose(result[key], expected_data) -class TestNormalizeStainsMacenkod(unittest.TestCase): +class TestNormalizeHEStainsD(unittest.TestCase): @unittest.skipUnless(has_cp, "Requires CuPy") def setUp(self): prepare_test_data() @@ -179,7 +179,7 @@ def setUp(self): @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1]) def test_transparent_image(self, image): """ - Test Macenko stain normalization on an image that comprises + 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 @@ -188,10 +188,10 @@ def test_transparent_image(self, image): key = "image" if image is None: with self.assertRaises(TypeError): - NormalizeStainsMacenkod([key])({key: image}) + NormalizeHEStainsD([key])({key: image}) else: with self.assertRaises(ValueError): - NormalizeStainsMacenkod([key])({key: image}) + NormalizeHEStainsD([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): @@ -242,9 +242,9 @@ def test_result_value(self, argments, image, expected_data): key = "image" if image is None: with self.assertRaises(TypeError): - NormalizeStainsMacenkod([key])({key: image}) + NormalizeHEStainsD([key])({key: image}) else: - result = NormalizeStainsMacenkod([key], **argments)({key: image}) + result = NormalizeHEStainsD([key], **argments)({key: image}) cp.testing.assert_allclose(result[key], expected_data) From 7621fb43df9a1db9cf14983a06dc1bcac1301e3a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 17:25:06 +0000 Subject: [PATCH 28/38] Add dictionary variant names Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/__init__.py | 9 ++++++--- monai/apps/pathology/transforms/__init__.py | 9 ++++++++- monai/apps/pathology/transforms/stain/__init__.py | 9 ++++++++- monai/apps/pathology/transforms/stain/dictionary.py | 4 ++++ ...athology_transforms.py => test_pathology_he_stain.py} | 0 ...gy_transformsd.py => test_pathology_he_stain_dict.py} | 0 6 files changed, 26 insertions(+), 5 deletions(-) rename tests/{test_pathology_transforms.py => test_pathology_he_stain.py} (100%) rename tests/{test_pathology_transformsd.py => test_pathology_he_stain_dict.py} (100%) diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index c67efdb823..0ada8fe51b 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -12,10 +12,13 @@ from .datasets import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCacheDataset from .handlers import ProbMapProducer from .metrics import LesionFROC -from .transforms.stain import ( - ExtractHEStains, +from .transforms.stain.array import ExtractHEStains, NormalizeHEStains +from .transforms.stain.dictionary import ( + ExtractHEStainsd, ExtractHEStainsD, - NormalizeHEStains, + ExtractHEStainsDict, + NormalizeHEStainsd, NormalizeHEStainsD, + NormalizeHEStainsDict, ) 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 index 9e750482bf..0df016244b 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -10,4 +10,11 @@ # limitations under the License. from .stain.array import ExtractHEStains, NormalizeHEStains -from .stain.dictionary import ExtractHEStainsD, NormalizeHEStainsD +from .stain.dictionary import ( + ExtractHEStainsd, + ExtractHEStainsD, + ExtractHEStainsDict, + NormalizeHEStainsd, + NormalizeHEStainsD, + NormalizeHEStainsDict, +) diff --git a/monai/apps/pathology/transforms/stain/__init__.py b/monai/apps/pathology/transforms/stain/__init__.py index 4cf6dc2b6e..824f40a579 100644 --- a/monai/apps/pathology/transforms/stain/__init__.py +++ b/monai/apps/pathology/transforms/stain/__init__.py @@ -10,4 +10,11 @@ # limitations under the License. from .array import ExtractHEStains, NormalizeHEStains -from .dictionary import ExtractHEStainsD, NormalizeHEStainsD +from .dictionary import ( + ExtractHEStainsd, + ExtractHEStainsD, + ExtractHEStainsDict, + NormalizeHEStainsd, + NormalizeHEStainsD, + NormalizeHEStainsDict, +) diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index 1041764782..ec9ba96eac 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -116,3 +116,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda for key in self.key_iterator(d): d[key] = self.normalizer(d[key]) return d + + +ExtractHEStainsDict = ExtractHEStainsd = ExtractHEStainsD +NormalizeHEStainsDict = NormalizeHEStainsd = NormalizeHEStainsD diff --git a/tests/test_pathology_transforms.py b/tests/test_pathology_he_stain.py similarity index 100% rename from tests/test_pathology_transforms.py rename to tests/test_pathology_he_stain.py diff --git a/tests/test_pathology_transformsd.py b/tests/test_pathology_he_stain_dict.py similarity index 100% rename from tests/test_pathology_transformsd.py rename to tests/test_pathology_he_stain_dict.py From e5b8c629459943ce6e9225a036252ba54e3c83e5 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 18:43:01 +0000 Subject: [PATCH 29/38] Fix typing and formatting Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- .../apps/pathology/transforms/stain/array.py | 33 +++++++++++-------- .../pathology/transforms/stain/dictionary.py | 11 ++++--- tests/test_pathology_he_stain.py | 2 -- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 729d59f7d4..748c68a7c2 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional import numpy as np @@ -34,7 +35,13 @@ 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: np.ndarray = None) -> None: + def __init__( + self, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + max_cref: Optional[np.ndarray] = None, + ) -> None: self.tli = tli self.alpha = alpha self.beta = beta @@ -43,7 +50,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 = np.array([1.9705, 1.0308]) - def _deconvolution_extract_stain(self, img: np.ndarray) -> np.ndarray: + def _deconvolution_extract_stain(self, image: np.ndarray) -> np.ndarray: """Perform Stain Deconvolution and return stain matrix for the image. Args: @@ -52,11 +59,10 @@ def _deconvolution_extract_stain(self, img: np.ndarray) -> np.ndarray: 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 = -np.log(np.clip(img.astype(np.float32) + 1, a_max=self.tli) / self.tli) + # reshape image and calculate absorbance + image = image.reshape((-1, 3)) + image = image.astype(np.float32) + 1.0 + absorbance = -np.log(image.clip(a_max=self.tli) / self.tli) # remove transparent pixels absorbance_hat = absorbance[np.all(absorbance > self.beta, axis=1)] @@ -132,14 +138,14 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - target_he: np.ndarray = None, - max_cref: np.ndarray = None, + target_he: Optional[np.ndarray] = None, + max_cref: Optional[np.ndarray] = None, ) -> None: self.tli = tli + if target_he is None: + target_he = np.array([[0.5626, 0.2159], [0.7201, 0.8012], [0.4062, 0.5581]]) self.target_he = target_he - if self.target_he is None: - self.target_he = np.array([[0.5626, 0.2159], [0.7201, 0.8012], [0.4062, 0.5581]]) self.max_cref = max_cref if self.max_cref is None: @@ -166,7 +172,8 @@ def __call__(self, image: np.ndarray) -> np.ndarray: # reshape image and calculate absorbance image = image.reshape((-1, 3)) - absorbance = -np.log(np.clip(image.astype(np.float32) + 1, a_max=self.tli) / self.tli) + image = image.astype(np.float32) + 1.0 + absorbance = -np.log(image.clip(a_max=self.tli) / self.tli) # rows correspond to channels (RGB), columns to absorbance values y = np.reshape(absorbance, (-1, 3)).T @@ -179,7 +186,7 @@ def __call__(self, image: np.ndarray) -> np.ndarray: tmp = np.divide(max_conc, self.max_cref, dtype=np.float32) image_c = np.divide(conc, tmp[:, np.newaxis], dtype=np.float32) - image_norm = np.multiply(self.tli, np.exp(-self.target_he.dot(image_c)), dtype=np.float32) + image_norm: np.ndarray = np.multiply(self.tli, np.exp(-self.target_he.dot(image_c)), dtype=np.float32) image_norm[image_norm > 255] = 254 image_norm = np.reshape(image_norm.T, (h, w, 3)).astype(np.uint8) return image_norm diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index ec9ba96eac..d18d4943f8 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -15,14 +15,15 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ -from typing import TYPE_CHECKING, Dict, Hashable, Mapping +from typing import Dict, Hashable, Mapping, Optional import numpy as np -from .array import ExtractHEStains, NormalizeHEStains from monai.config import KeysCollection from monai.transforms.transform import MapTransform +from .array import ExtractHEStains, NormalizeHEStains + class ExtractHEStainsD(MapTransform): """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.ExtractHEStains`. @@ -53,7 +54,7 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - max_cref: np.ndarray = None, + max_cref: Optional[np.ndarray] = None, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) @@ -104,8 +105,8 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - target_he: np.ndarray = None, - max_cref: np.ndarray = None, + target_he: Optional[np.ndarray] = None, + max_cref: Optional[np.ndarray] = None, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) diff --git a/tests/test_pathology_he_stain.py b/tests/test_pathology_he_stain.py index bc268ef95c..9057d0dfa2 100644 --- a/tests/test_pathology_he_stain.py +++ b/tests/test_pathology_he_stain.py @@ -12,12 +12,10 @@ import unittest import numpy as np - from parameterized import parameterized from monai.apps.pathology.transforms import ExtractHEStains, NormalizeHEStains - EXTRACT_STAINS_TEST_CASE_1 = (None,) EXTRACT_STAINS_TEST_CASE_2 = (None,) EXTRACT_STAINS_TEST_CASE_3 = (None,) From b157c7c9e9cc043258e392f7aaeb4ccf0b990305 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 26 Jul 2021 18:49:15 +0000 Subject: [PATCH 30/38] Fix docs Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/stain/array.py | 6 ++++-- .../apps/pathology/transforms/stain/dictionary.py | 14 +------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 748c68a7c2..2d9a27ad76 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -31,8 +31,9 @@ class ExtractHEStains(Transform): 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 + + - MATLAB: https://github.com/mitkovetta/staining-normalization + - Python: https://github.com/schaugf/HEnorm_python """ def __init__( @@ -129,6 +130,7 @@ class NormalizeHEStains(Transform): 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 """ diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index d18d4943f8..c517cdd11f 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -40,12 +40,6 @@ class ExtractHEStainsD(MapTransform): 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__( @@ -70,7 +64,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda class NormalizeHEStainsD(MapTransform): """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.NormalizeHEStains`. - Class to normalize patches/images to a reference or target image stain (see Note). + Class to normalize patches/images to a reference or target image stain. Performs stain deconvolution of the source image using the ExtractHEStains class, to obtain the stain matrix and calculate the stain concentration matrix @@ -91,12 +85,6 @@ class NormalizeHEStainsD(MapTransform): 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__( From a050965e21addf06e8846b5b000bde2278a8ec69 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 28 Jul 2021 16:24:20 +0000 Subject: [PATCH 31/38] Update test cases Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- tests/test_pathology_he_stain.py | 142 ++++++++++------------- tests/test_pathology_he_stain_dict.py | 157 +++++++++++--------------- 2 files changed, 121 insertions(+), 178 deletions(-) diff --git a/tests/test_pathology_he_stain.py b/tests/test_pathology_he_stain.py index 9057d0dfa2..562b52c856 100644 --- a/tests/test_pathology_he_stain.py +++ b/tests/test_pathology_he_stain.py @@ -16,86 +16,56 @@ from monai.apps.pathology.transforms import ExtractHEStains, NormalizeHEStains -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 = [ - np.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 = [ - np.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 = [ - np.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 = [ - 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 - global EXTRACT_STAINS_TEST_CASE_5 - 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 - global NORMALIZE_STAINS_TEST_CASE_1 - NORMALIZE_STAINS_TEST_CASE_1 = [ - np.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": 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 - global NORMALIZE_STAINS_TEST_CASE_3 - 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 - global NORMALIZE_STAINS_TEST_CASE_4 - 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]]]), - ] +# 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 = ({}, 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 TestExtractHEStains(unittest.TestCase): - def setUp(self): - prepare_test_data() - - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) + @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 @@ -111,7 +81,7 @@ def test_transparent_image(self, image): with self.assertRaises(ValueError): ExtractHEStains()(image) - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) + @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 @@ -128,7 +98,7 @@ def test_identical_result_vectors(self, image): result = ExtractHEStains()(image) np.testing.assert_array_equal(result[:, 0], result[:, 1]) - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) + @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. @@ -165,10 +135,7 @@ def test_result_value(self, image, expected_data): class TestNormalizeHEStains(unittest.TestCase): - def setUp(self): - prepare_test_data() - - @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1]) + @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 @@ -184,7 +151,14 @@ def test_transparent_image(self, image): with self.assertRaises(ValueError): NormalizeHEStains()(image) - @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3, NORMALIZE_STAINS_TEST_CASE_4]) + @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, argments, image, expected_data): """ Test that an input image returns an expected normalized image. diff --git a/tests/test_pathology_he_stain_dict.py b/tests/test_pathology_he_stain_dict.py index b63f5cbbef..d523575fba 100644 --- a/tests/test_pathology_he_stain_dict.py +++ b/tests/test_pathology_he_stain_dict.py @@ -11,95 +11,61 @@ import unittest +import numpy as np from parameterized import parameterized from monai.apps.pathology.transforms import ExtractHEStainsD, NormalizeHEStainsD -from monai.utils import optional_import - -cp, has_cp = optional_import("cupy") - - -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]]]), - ] +# 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 = ({}, None, None) -class TestExtractHEStainsD(unittest.TestCase): - @unittest.skipUnless(has_cp, "Requires CuPy") - def setUp(self): - prepare_test_data() +# 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)] - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_1]) +# 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 @@ -116,7 +82,7 @@ def test_transparent_image(self, image): with self.assertRaises(ValueError): ExtractHEStainsD([key])({key: image}) - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) + @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 @@ -132,9 +98,9 @@ def test_identical_result_vectors(self, image): ExtractHEStainsD([key])({key: image}) else: result = ExtractHEStainsD([key])({key: image}) - cp.testing.assert_array_equal(result[key][:, 0], result[key][:, 1]) + np.testing.assert_array_equal(result[key][:, 0], result[key][:, 1]) - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) + @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. @@ -168,15 +134,11 @@ def test_result_value(self, image, expected_data): ExtractHEStainsD([key])({key: image}) else: result = ExtractHEStainsD([key])({key: image}) - cp.testing.assert_allclose(result[key], expected_data) + np.testing.assert_allclose(result[key], expected_data) class TestNormalizeHEStainsD(unittest.TestCase): - @unittest.skipUnless(has_cp, "Requires CuPy") - def setUp(self): - prepare_test_data() - - @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_1]) + @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 @@ -193,7 +155,14 @@ def test_transparent_image(self, image): with self.assertRaises(ValueError): NormalizeHEStainsD([key])({key: image}) - @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_2, NORMALIZE_STAINS_TEST_CASE_3, NORMALIZE_STAINS_TEST_CASE_4]) + @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, argments, image, expected_data): """ Test that an input image returns an expected normalized image. @@ -245,7 +214,7 @@ def test_result_value(self, argments, image, expected_data): NormalizeHEStainsD([key])({key: image}) else: result = NormalizeHEStainsD([key], **argments)({key: image}) - cp.testing.assert_allclose(result[key], expected_data) + np.testing.assert_allclose(result[key], expected_data) if __name__ == "__main__": From 34c20c3f7d0d7d820ad746ea019f45e364db3a0b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 28 Jul 2021 16:24:37 +0000 Subject: [PATCH 32/38] Fix clip max Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/stain/array.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 2d9a27ad76..ae30d5006e 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -63,7 +63,7 @@ def _deconvolution_extract_stain(self, image: np.ndarray) -> np.ndarray: # reshape image and calculate absorbance image = image.reshape((-1, 3)) image = image.astype(np.float32) + 1.0 - absorbance = -np.log(image.clip(a_max=self.tli) / self.tli) + absorbance = -np.log(image.clip(max=self.tli) / self.tli) # remove transparent pixels absorbance_hat = absorbance[np.all(absorbance > self.beta, axis=1)] @@ -175,7 +175,7 @@ def __call__(self, image: np.ndarray) -> np.ndarray: # reshape image and calculate absorbance image = image.reshape((-1, 3)) image = image.astype(np.float32) + 1.0 - absorbance = -np.log(image.clip(a_max=self.tli) / self.tli) + absorbance = -np.log(image.clip(max=self.tli) / self.tli) # rows correspond to channels (RGB), columns to absorbance values y = np.reshape(absorbance, (-1, 3)).T From 010326d2ef670bb661aa378c76a88803e5b2ef19 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 28 Jul 2021 17:04:13 +0000 Subject: [PATCH 33/38] Fix var typing Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- tests/test_pathology_he_stain.py | 10 ++++++++-- tests/test_pathology_he_stain_dict.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_pathology_he_stain.py b/tests/test_pathology_he_stain.py index 562b52c856..af0c79db7b 100644 --- a/tests/test_pathology_he_stain.py +++ b/tests/test_pathology_he_stain.py @@ -20,7 +20,7 @@ EXTRACT_STAINS_TEST_CASE_0 = (None,) EXTRACT_STAINS_TEST_CASE_00 = (None, None) NORMALIZE_STAINS_TEST_CASE_0 = (None,) -NORMALIZE_STAINS_TEST_CASE_00 = ({}, None, 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)] @@ -98,7 +98,13 @@ def test_identical_result_vectors(self, image): result = ExtractHEStains()(image) np.testing.assert_array_equal(result[:, 0], result[:, 1]) - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_00, EXTRACT_STAINS_TEST_CASE_4, EXTRACT_STAINS_TEST_CASE_5]) + @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. diff --git a/tests/test_pathology_he_stain_dict.py b/tests/test_pathology_he_stain_dict.py index d523575fba..8d51579cb2 100644 --- a/tests/test_pathology_he_stain_dict.py +++ b/tests/test_pathology_he_stain_dict.py @@ -20,7 +20,7 @@ EXTRACT_STAINS_TEST_CASE_0 = (None,) EXTRACT_STAINS_TEST_CASE_00 = (None, None) NORMALIZE_STAINS_TEST_CASE_0 = (None,) -NORMALIZE_STAINS_TEST_CASE_00 = ({}, None, 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)] @@ -100,7 +100,13 @@ def test_identical_result_vectors(self, image): 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]) + @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. From d47312e7ad0faf34a212b0490f0301896e0fcfa8 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 28 Jul 2021 23:53:41 +0000 Subject: [PATCH 34/38] Fix a typing issue Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/stain/array.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index ae30d5006e..09c790760e 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -149,9 +149,9 @@ def __init__( target_he = np.array([[0.5626, 0.2159], [0.7201, 0.8012], [0.4062, 0.5581]]) self.target_he = target_he + if max_cref is None: + max_cref = np.array([1.9705, 1.0308]) self.max_cref = max_cref - if self.max_cref is None: - self.max_cref = np.array([1.9705, 1.0308]) self.stain_extractor = ExtractHEStains(tli=self.tli, alpha=alpha, beta=beta, max_cref=self.max_cref) From 5f0681d9674a2a0f444ddb4b4d028d7d2da0216d Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Fri, 30 Jul 2021 03:13:10 +0000 Subject: [PATCH 35/38] Update default values, and change D to d Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- .../apps/pathology/transforms/stain/array.py | 32 +++++++------------ .../pathology/transforms/stain/dictionary.py | 18 +++++------ 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 09c790760e..6c5eb2d462 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Optional +from typing import Union import numpy as np @@ -25,7 +25,7 @@ class ExtractHEStains(Transform): 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. + Defaults to (1.9705, 1.0308). Note: For more information refer to: @@ -41,21 +41,18 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - max_cref: Optional[np.ndarray] = None, + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), ) -> None: self.tli = tli self.alpha = alpha self.beta = beta - - self.max_cref = max_cref - if self.max_cref is None: - self.max_cref = np.array([1.9705, 1.0308]) + self.max_cref = np.array(max_cref) def _deconvolution_extract_stain(self, image: np.ndarray) -> np.ndarray: """Perform Stain Deconvolution and return stain matrix for the image. Args: - img: uint8 RGB image to perform stain deconvolution of + img: uint8 RGB image to perform stain deconvolution on Return: he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) @@ -122,9 +119,9 @@ class NormalizeHEStains(Transform): 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. + target_he: target stain matrix. Defaults to ((0.5626, 0.2159), (0.7201, 0.8012), (0.4062, 0.5581)). max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). - Defaults to None. + Defaults to [1.9705, 1.0308]. Note: For more information refer to: @@ -140,19 +137,12 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - target_he: Optional[np.ndarray] = None, - max_cref: Optional[np.ndarray] = None, + target_he: Union[tuple, np.ndarray] = ((0.5626, 0.2159), (0.7201, 0.8012), (0.4062, 0.5581)), + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), ) -> None: self.tli = tli - - if target_he is None: - target_he = np.array([[0.5626, 0.2159], [0.7201, 0.8012], [0.4062, 0.5581]]) - self.target_he = target_he - - if max_cref is None: - max_cref = np.array([1.9705, 1.0308]) - self.max_cref = max_cref - + self.target_he = np.array(target_he) + self.max_cref = np.array(max_cref) self.stain_extractor = ExtractHEStains(tli=self.tli, alpha=alpha, beta=beta, max_cref=self.max_cref) def __call__(self, image: np.ndarray) -> np.ndarray: diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py index c517cdd11f..976af1e7c7 100644 --- a/monai/apps/pathology/transforms/stain/dictionary.py +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -15,7 +15,7 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ -from typing import Dict, Hashable, Mapping, Optional +from typing import Dict, Hashable, Mapping, Union import numpy as np @@ -25,7 +25,7 @@ from .array import ExtractHEStains, NormalizeHEStains -class ExtractHEStainsD(MapTransform): +class ExtractHEStainsd(MapTransform): """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.ExtractHEStains`. Class to extract a target stain from an image, using stain deconvolution. @@ -37,7 +37,7 @@ class ExtractHEStainsD(MapTransform): 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. + Defaults to (1.9705, 1.0308). allow_missing_keys: don't raise exception if key is missing. """ @@ -48,7 +48,7 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - max_cref: Optional[np.ndarray] = None, + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) @@ -61,7 +61,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class NormalizeHEStainsD(MapTransform): +class NormalizeHEStainsd(MapTransform): """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.NormalizeHEStains`. Class to normalize patches/images to a reference or target image stain. @@ -93,8 +93,8 @@ def __init__( tli: float = 240, alpha: float = 1, beta: float = 0.15, - target_he: Optional[np.ndarray] = None, - max_cref: Optional[np.ndarray] = None, + target_he: Union[tuple, np.ndarray] = ((0.5626, 0.2159), (0.7201, 0.8012), (0.4062, 0.5581)), + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) @@ -107,5 +107,5 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -ExtractHEStainsDict = ExtractHEStainsd = ExtractHEStainsD -NormalizeHEStainsDict = NormalizeHEStainsd = NormalizeHEStainsD +ExtractHEStainsDict = ExtractHEStainsD = ExtractHEStainsd +NormalizeHEStainsDict = NormalizeHEStainsD = NormalizeHEStainsd From d398047aa359eadb035137cc80cb0b096e099497 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Fri, 30 Jul 2021 03:13:47 +0000 Subject: [PATCH 36/38] Update docs Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- docs/source/apps.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 6827e58056..959e42d6f9 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -106,7 +106,7 @@ Clara MMARs :members: .. automodule:: monai.apps.pathology.transforms.stain.dictionary -.. autoclass:: ExtractHEStainsD +.. autoclass:: ExtractHEStainsd :members: -.. autoclass:: NormalizeHEStainsD +.. autoclass:: NormalizeHEStainsd :members: From 9daead331fc5da0772754aa8cbc0fa380ad62d44 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Fri, 30 Jul 2021 03:34:15 +0000 Subject: [PATCH 37/38] Add image value check Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/transforms/stain/array.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 6c5eb2d462..ccddc6b243 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -57,6 +57,14 @@ def _deconvolution_extract_stain(self, image: np.ndarray) -> np.ndarray: Return: he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) """ + # check image type and vlues + if not isinstance(image, np.ndarray): + raise TypeError("Image must be of type numpy.ndarray.") + if image.min() < 0: + raise ValueError("Image should not have negative values.") + if image.max() > 255: + raise ValueError("Image should not have values greater than 255.") + # reshape image and calculate absorbance image = image.reshape((-1, 3)) image = image.astype(np.float32) + 1.0 @@ -149,20 +157,24 @@ def __call__(self, image: np.ndarray) -> np.ndarray: """Perform stain normalization. Args: - image: uint8 RGB image/patch to stain normalize + image: uint8 RGB image/patch to be stain normalized, pixel values between 0 and 255 Return: image_norm: stain normalized image/patch """ + # check image type and vlues if not isinstance(image, np.ndarray): raise TypeError("Image must be of type numpy.ndarray.") + if image.min() < 0: + raise ValueError("Image should not have negative values.") + if image.max() > 255: + raise ValueError("Image should not have values greater than 255.") # extract stain of the image he = self.stain_extractor(image) - h, w, _ = image.shape - # reshape image and calculate absorbance + h, w, _ = image.shape image = image.reshape((-1, 3)) image = image.astype(np.float32) + 1.0 absorbance = -np.log(image.clip(max=self.tli) / self.tli) From bca9815276d71967d52ff255df633c2abd709aff Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Fri, 30 Jul 2021 03:35:00 +0000 Subject: [PATCH 38/38] Add test cases for negative and invalid values Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- tests/test_pathology_he_stain.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/test_pathology_he_stain.py b/tests/test_pathology_he_stain.py index af0c79db7b..1d74f485e9 100644 --- a/tests/test_pathology_he_stain.py +++ b/tests/test_pathology_he_stain.py @@ -22,6 +22,12 @@ NORMALIZE_STAINS_TEST_CASE_0 = (None,) NORMALIZE_STAINS_TEST_CASE_00: tuple = ({}, None, None) +# input pixels with negative values +NEGATIVE_VALUE_TEST_CASE = [np.full((3, 2, 3), -1)] + +# input pixels with greater than 255 values +INVALID_VALUE_TEST_CASE = [np.full((3, 2, 3), 256)] + # input pixels all transparent and below the beta absorbance threshold EXTRACT_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] @@ -43,6 +49,7 @@ 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)] @@ -65,7 +72,14 @@ class TestExtractHEStains(unittest.TestCase): - @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_1]) + @parameterized.expand( + [ + NEGATIVE_VALUE_TEST_CASE, + INVALID_VALUE_TEST_CASE, + 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 @@ -141,7 +155,14 @@ def test_result_value(self, image, expected_data): class TestNormalizeHEStains(unittest.TestCase): - @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_0, NORMALIZE_STAINS_TEST_CASE_1]) + @parameterized.expand( + [ + NEGATIVE_VALUE_TEST_CASE, + INVALID_VALUE_TEST_CASE, + 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