-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Stain normalization #2666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+838
−0
Merged
Stain normalization #2666
Changes from all commits
Commits
Show all changes
57 commits
Select commit
Hold shift + click to select a range
3e2de71
added stain norm and tests
nsrivathsa cc6bc76
Merge branch 'master' into neha/stain-norm-2
bhashemian e197f9d
import changes
nsrivathsa ad957fa
changed stain extraction tests
nsrivathsa 00518fa
edited stain norm tests
nsrivathsa 45a2ac5
convert floats to float32
nsrivathsa 4b0f696
added uint8 assumption to docstring
nsrivathsa cb99aef
add error case
nsrivathsa ad29340
Merge branch 'master' into neha/stain-norm-2
bhashemian 3fcc366
formatting change
nsrivathsa fa95e0d
modify tests wrt cupy import
nsrivathsa 4746f33
Merge branch 'master' into neha/stain-norm-2
nsrivathsa f3b2909
minor change to pass lint test
nsrivathsa b80fc17
import changes
nsrivathsa 1e36ebc
Merge branch 'master' into neha/stain-norm-2
bhashemian 05ec786
refactored classes
nsrivathsa bcb2211
Restructure and rename transforms
bhashemian a922bc2
Merge branch 'dev' into neha/stain-norm-2
bhashemian 283444e
added dict transform
nsrivathsa dbb95d8
Merge branch 'dev' into neha/stain-norm-2
bhashemian 7440a5b
Move stain_extractor to init
bhashemian f6e9b38
Exclude pathology transform tests from mini tests
bhashemian 26c9e5a
Merge branch 'dev' into neha/stain-norm-2
nsrivathsa 72e0448
Fix type checking for cupy ndarray
bhashemian 58cc4b7
Merge branch 'neha/stain-norm-2' of https://github.com/nsrivathsa/MON…
bhashemian be88f7d
Include pathology transform tests
bhashemian 3589326
Merge branch 'dev' into neha/stain-norm-2
bhashemian dd5d82e
Update to cupy 9.0.0
bhashemian 128f01d
Remove exact version for cupy
bhashemian a462c97
Merge branch 'neha/stain-norm-2' of https://github.com/nsrivathsa/MON…
bhashemian 0c7d3b0
add to docs
nsrivathsa c20cfa2
Merge branch 'dev' into neha/stain-norm-2
nsrivathsa 33edd90
Merge branch 'dev' into neha/stain-norm-2
bhashemian e1aa836
Merge branch 'dev' into neha/stain-norm-2
bhashemian b262f87
Merge branch 'dev' into neha/stain-norm-2
bhashemian da99271
Merge branch 'dev' into neha/stain-norm-2
bhashemian d2d7a8a
Organize into stain dir
bhashemian eb411d7
Add/update init files
bhashemian 9ddb4e1
Transit all from cupy to numpy
bhashemian 24cdf11
Update imports
bhashemian f26ef3a
Update test cases for numpy
bhashemian e7ad5a5
Rename to NormalizeHEStains and NormalizeHEStainsD
bhashemian 7621fb4
Add dictionary variant names
bhashemian f8934c3
Merge branch 'dev' of github.com:Project-MONAI/MONAI into stain-norma…
bhashemian e5b8c62
Fix typing and formatting
bhashemian b157c7c
Fix docs
bhashemian a050965
Update test cases
bhashemian 34c20c3
Fix clip max
bhashemian 010326d
Fix var typing
bhashemian ba52324
Merge branch 'dev' into stain-normalization
bhashemian d47312e
Fix a typing issue
bhashemian 721cba9
Merge branch 'dev' into stain-normalization
bhashemian 5f0681d
Update default values, and change D to d
bhashemian d398047
Update docs
bhashemian 9daead3
Add image value check
bhashemian bca9815
Add test cases for negative and invalid values
bhashemian 4248261
Merge branch 'stain-normalization' of github.com:behxyz/MONAI into st…
bhashemian File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # 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 .stain.array import ExtractHEStains, NormalizeHEStains | ||
| from .stain.dictionary import ( | ||
| ExtractHEStainsd, | ||
| ExtractHEStainsD, | ||
| ExtractHEStainsDict, | ||
| NormalizeHEStainsd, | ||
| NormalizeHEStainsD, | ||
| NormalizeHEStainsDict, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # 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 .array import ExtractHEStains, NormalizeHEStains | ||
| from .dictionary import ( | ||
| ExtractHEStainsd, | ||
| ExtractHEStainsD, | ||
| ExtractHEStainsDict, | ||
| NormalizeHEStainsd, | ||
| NormalizeHEStainsD, | ||
| NormalizeHEStainsDict, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| # 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 Union | ||
|
|
||
| import numpy as np | ||
|
|
||
| from monai.transforms.transform import Transform | ||
|
|
||
|
|
||
| class ExtractHEStains(Transform): | ||
| """Class to extract a target stain from an image, using stain deconvolution (see Note). | ||
|
|
||
| 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 (1.9705, 1.0308). | ||
|
|
||
| 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: Union[tuple, np.ndarray] = (1.9705, 1.0308), | ||
| ) -> None: | ||
| self.tli = tli | ||
| self.alpha = alpha | ||
| self.beta = beta | ||
| 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 on | ||
|
|
||
| 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 | ||
| absorbance = -np.log(image.clip(max=self.tli) / self.tli) | ||
|
|
||
| # remove transparent pixels | ||
| 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 = 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 = 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 = 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 | ||
|
|
||
| return he | ||
|
|
||
| def __call__(self, image: np.ndarray) -> np.ndarray: | ||
| """Perform stain extraction. | ||
|
|
||
| Args: | ||
| 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) | ||
| """ | ||
| 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 | ||
|
|
||
|
|
||
| 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 | ||
| 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 ((0.5626, 0.2159), (0.7201, 0.8012), (0.4062, 0.5581)). | ||
| max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Default values of |
||
| Defaults to [1.9705, 1.0308]. | ||
|
|
||
| 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, | ||
| 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 | ||
| 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: | ||
| """Perform stain normalization. | ||
|
|
||
| Args: | ||
| 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) | ||
|
|
||
| # 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) | ||
|
|
||
| # rows correspond to channels (RGB), columns to absorbance values | ||
| y = np.reshape(absorbance, (-1, 3)).T | ||
|
|
||
| # determine concentrations of the individual stains | ||
| conc = np.linalg.lstsq(he, y, rcond=None)[0] | ||
|
|
||
| # normalize stain concentrations | ||
| 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: 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| # 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. | ||
| """ | ||
|
|
||
| from typing import Dict, Hashable, Mapping, Union | ||
|
|
||
| import numpy as np | ||
|
|
||
| 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`. | ||
| Class to extract a target stain from an image, using 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 (1.9705, 1.0308). | ||
| allow_missing_keys: don't raise exception if key is missing. | ||
|
|
||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| keys: KeysCollection, | ||
| tli: float = 240, | ||
| alpha: float = 1, | ||
| beta: float = 0.15, | ||
| max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), | ||
| 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, np.ndarray]) -> Dict[Hashable, np.ndarray]: | ||
| d = dict(data) | ||
| for key in self.key_iterator(d): | ||
| d[key] = self.extractor(d[key]) | ||
| return d | ||
|
|
||
|
|
||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| keys: KeysCollection, | ||
| tli: float = 240, | ||
| alpha: float = 1, | ||
| beta: float = 0.15, | ||
| 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) | ||
| 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) | ||
| for key in self.key_iterator(d): | ||
| d[key] = self.normalizer(d[key]) | ||
| return d | ||
|
|
||
|
|
||
| ExtractHEStainsDict = ExtractHEStainsD = ExtractHEStainsd | ||
| NormalizeHEStainsDict = NormalizeHEStainsD = NormalizeHEStainsd |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need some validation of the input of np.log?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good point! We should check if the input is intensity (0-255) and not any image or other scales. Thanks @wyli!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now it checks the image values to be between 0 and 255.