From c30d291f1fda9e815f8d030a99e25693ae6a6d8f Mon Sep 17 00:00:00 2001 From: Iyassou Shimels Date: Mon, 1 Sep 2025 14:00:00 +0100 Subject: [PATCH 1/2] fix: correct H&E stain ordering heuristic in ExtractHEStains The previous heuristic incorrectly assumed hematoxylin has higher red channel values than eosin, when the opposite is typically true. Replace red-channel-only comparison with more robust red-blue ratio comparison to better distinguish hematoxylin from eosin stains based on their spectral properties. - Hematoxylin (nuclear, blue): lower red/blue ratio -> first column - Eosin (cytoplasm, pink): higher red/blue ratio -> second column Update tests to reflect corrected stain ordering. Documented behaviour (first column = H, second = E) now matches the actual output. Signed-off-by: Iyassou Shimels --- monai/apps/pathology/transforms/stain/array.py | 7 ++++++- .../transforms/test_pathology_he_stain.py | 16 ++++++++-------- .../transforms/test_pathology_he_stain_dict.py | 16 ++++++++-------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 5df9ad7ef3..676cec0113 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -85,7 +85,12 @@ def _deconvolution_extract_stain(self, image: np.ndarray) -> np.ndarray: 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]: + # Hematoxylin: high blue, lower red (low R/B ratio) + # Eosin: high red, lower blue (high R/B ratio) + ε = np.finfo(np.float32).eps + v_min_rb_ratio = v_min[0, 0] / (v_min[2, 0] + ε) + v_max_rb_ratio = v_max[0, 0] / (v_max[2, 0] + ε) + if v_min_rb_ratio < v_max_rb_ratio: he = np.array((v_min[:, 0], v_max[:, 0]), dtype=np.float32).T else: he = np.array((v_max[:, 0], v_min[:, 0]), dtype=np.float32).T diff --git a/tests/apps/pathology/transforms/test_pathology_he_stain.py b/tests/apps/pathology/transforms/test_pathology_he_stain.py index 26941c6abb..e40a6921c7 100644 --- a/tests/apps/pathology/transforms/test_pathology_he_stain.py +++ b/tests/apps/pathology/transforms/test_pathology_he_stain.py @@ -48,7 +48,7 @@ # 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]]), + np.array([[0.18696113, 0.70710677], [0.0, 0.0], [0.98236734, 0.70710677]]), ] # input pixels all transparent and below the beta absorbance threshold @@ -68,7 +68,7 @@ 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]]]), + np.array([[[31, 31, 31], [85, 85, 85]], [[85, 85, 85], [85, 85, 85]], [[85, 85, 85], [85, 85, 85]]]), ] @@ -135,7 +135,7 @@ def test_result_value(self, image, expected_data): [[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]] + [[0.18696113,0.70710677],[0,0],[0.98236734,0.70710677]] """ if image is None: with self.assertRaises(TypeError): @@ -206,17 +206,17 @@ def test_result_value(self, arguments, 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 + [[0.18696113,0.70710677],[0,0],[0.98236734,0.70710677]], 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]] + matrix that should be [[5.8022, 0, 0, 0, 0, 0], + [-0.3101, 7.7508, 7.7508, 7.7508, 7.7508, 7.7508]] - 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]]] + image should be [[[31, 31, 31], [85, 85, 85]], [[85, 85, 85], [85, 85, 85]], + [[85, 85, 85], [85, 85, 85]]] """ if image is None: with self.assertRaises(TypeError): diff --git a/tests/apps/pathology/transforms/test_pathology_he_stain_dict.py b/tests/apps/pathology/transforms/test_pathology_he_stain_dict.py index 975dc4ffb8..973fe9075f 100644 --- a/tests/apps/pathology/transforms/test_pathology_he_stain_dict.py +++ b/tests/apps/pathology/transforms/test_pathology_he_stain_dict.py @@ -42,7 +42,7 @@ # 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]]), + np.array([[0.18696113, 0.70710677], [0.0, 0.0], [0.98236734, 0.70710677]]), ] # input pixels all transparent and below the beta absorbance threshold @@ -62,7 +62,7 @@ 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]]]), + np.array([[[31, 31, 31], [85, 85, 85]], [[85, 85, 85], [85, 85, 85]], [[85, 85, 85], [85, 85, 85]]]), ] @@ -129,7 +129,7 @@ def test_result_value(self, image, expected_data): [[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]] + [[0.18696113,0.70710677],[0,0],[0.98236734,0.70710677]] """ key = "image" if image is None: @@ -200,17 +200,17 @@ def test_result_value(self, arguments, 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 + [[0.18696113,0.70710677],[0,0],[0.98236734,0.70710677]], 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]] + matrix that should be [[5.8022, 0, 0, 0, 0, 0], + [-0.3101, 7.7508, 7.7508, 7.7508, 7.7508, 7.7508]] - 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]]] + image should be [[[31, 31, 31], [85, 85, 85]], [[85, 85, 85], [85, 85, 85]], + [[85, 85, 85], [85, 85, 85]]] """ key = "image" if image is None: From dde2c606820a524dca88c482f575695a403b84a0 Mon Sep 17 00:00:00 2001 From: Bruce Hashemian <3968947+bhashemian@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:02:59 -0400 Subject: [PATCH 2/2] Use ascii instead of unicode and remove uncessary index Signed-off-by: Bruce Hashemian <3968947+bhashemian@users.noreply.github.com> --- monai/apps/pathology/transforms/stain/array.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py index 676cec0113..266f5a74b2 100644 --- a/monai/apps/pathology/transforms/stain/array.py +++ b/monai/apps/pathology/transforms/stain/array.py @@ -87,9 +87,9 @@ def _deconvolution_extract_stain(self, image: np.ndarray) -> np.ndarray: # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second # Hematoxylin: high blue, lower red (low R/B ratio) # Eosin: high red, lower blue (high R/B ratio) - ε = np.finfo(np.float32).eps - v_min_rb_ratio = v_min[0, 0] / (v_min[2, 0] + ε) - v_max_rb_ratio = v_max[0, 0] / (v_max[2, 0] + ε) + eps = np.finfo(np.float32).eps + v_min_rb_ratio = v_min[0] / (v_min[2] + eps) + v_max_rb_ratio = v_max[0] / (v_max[2] + eps) if v_min_rb_ratio < v_max_rb_ratio: he = np.array((v_min[:, 0], v_max[:, 0]), dtype=np.float32).T else: