From bde067e20d1b8df05412b7ae1da45cb64df0b638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Sat, 3 Dec 2022 23:55:35 +0100 Subject: [PATCH 01/19] added torch reinhard base support --- torchstain/base/normalizers/reinhard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/torchstain/base/normalizers/reinhard.py b/torchstain/base/normalizers/reinhard.py index 96947aa..f96f087 100644 --- a/torchstain/base/normalizers/reinhard.py +++ b/torchstain/base/normalizers/reinhard.py @@ -3,7 +3,8 @@ def ReinhardNormalizer(backend='numpy'): from torchstain.numpy.normalizers import NumpyReinhardNormalizer return NumpyReinhardNormalizer() elif backend == "torch": - raise NotImplementedError + from torchstain.torch.normalizers import TorchReinhardNormalizer + return TorchReinhardNormalizer() elif backend == "tensorflow": from torchstain.tf.normalizers import TensorFlowReinhardNormalizer return TensorFlowReinhardNormalizer() From 03d499a65c5c033c303c640187b134aa9787f5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Sat, 3 Dec 2022 23:56:18 +0100 Subject: [PATCH 02/19] removed redundant import and added newline in tf --- torchstain/tf/normalizers/__init__.py | 2 +- torchstain/tf/utils/split.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/torchstain/tf/normalizers/__init__.py b/torchstain/tf/normalizers/__init__.py index 0916e1b..fb0718f 100644 --- a/torchstain/tf/normalizers/__init__.py +++ b/torchstain/tf/normalizers/__init__.py @@ -1,2 +1,2 @@ from torchstain.tf.normalizers.macenko import TensorFlowMacenkoNormalizer -from torchstain.tf.normalizers.reinhard import TensorFlowReinhardNormalizer \ No newline at end of file +from torchstain.tf.normalizers.reinhard import TensorFlowReinhardNormalizer diff --git a/torchstain/tf/utils/split.py b/torchstain/tf/utils/split.py index 226cde0..5717b87 100644 --- a/torchstain/tf/utils/split.py +++ b/torchstain/tf/utils/split.py @@ -1,5 +1,4 @@ import tensorflow as tf -from torchstain.tf.utils.rgb2lab import rgb2lab def csplit(I): return [I[..., i] for i in range(I.shape[-1])] From ce443d861cc845b2167ef080d2e203fb1931a3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Sat, 3 Dec 2022 23:56:46 +0100 Subject: [PATCH 03/19] added all utils for torch --- torchstain/torch/utils/__init__.py | 4 +++ torchstain/torch/utils/lab2rgb.py | 35 ++++++++++++++++++++++++ torchstain/torch/utils/rgb2lab.py | 44 ++++++++++++++++++++++++++++++ torchstain/torch/utils/split.py | 15 ++++++++++ torchstain/torch/utils/stats.py | 7 +++++ 5 files changed, 105 insertions(+) create mode 100644 torchstain/torch/utils/lab2rgb.py create mode 100644 torchstain/torch/utils/rgb2lab.py create mode 100644 torchstain/torch/utils/split.py create mode 100644 torchstain/torch/utils/stats.py diff --git a/torchstain/torch/utils/__init__.py b/torchstain/torch/utils/__init__.py index 5e0de3e..4acea5a 100644 --- a/torchstain/torch/utils/__init__.py +++ b/torchstain/torch/utils/__init__.py @@ -1,2 +1,6 @@ from torchstain.torch.utils.cov import cov from torchstain.torch.utils.percentile import percentile +from torchstain.torch.utils.stats import * +from torchstain.torch.utils.split import * +from torchstain.torch.utils.rgb2lab import * +from torchstain.torch.utils.lab2rgb import * diff --git a/torchstain/torch/utils/lab2rgb.py b/torchstain/torch/utils/lab2rgb.py new file mode 100644 index 0000000..623be42 --- /dev/null +++ b/torchstain/torch/utils/lab2rgb.py @@ -0,0 +1,35 @@ +import torch +from torchstain.torch.utils.rgb2lab import _rgb2xyz, _white + +_xyz2rgb = torch.linalg.inv(_rgb2xyz) + +def lab2rgb(lab): + lab = lab.type(torch.float32) + + # rescale back from OpenCV format and extract LAB channel + L, a, b = lab[0] / 2.55, lab[1] - 128, lab[2] - 128 + + # vector scaling to produce X, Y, Z + y = (L + 16.) / 116. + x = (a / 500.) + y + z = y - (b / 200.) + + # merge back to get reconstructed XYZ color image + out = torch.stack([x, y, z], axis=0) + + # apply boolean transforms + mask = out > 0.2068966 + not_mask = torch.logical_not(mask) + out.masked_scatter_(mask, torch.pow(out, 3)) + out.masked_scatter_(not_mask, (out - 16 / 116) / 7.787) + + # rescale to the reference white (illuminant) + out = torch.mul(out, _white.type(out.dtype).unsqueeze(dim=-1).unsqueeze(dim=-1)) + + # convert XYZ -> RGB color domain + arr = torch.tensordot(out, torch.t(_xyz2rgb).type(out.dtype), dims=([0], [0])) + mask = arr > 0.0031308 + not_mask = torch.logical_not(mask) + arr.masked_scatter(mask, 1.055 * torch.pow(arr, 1 / 2.4) - 0.055) + arr.masked_scatter(not_mask, arr * 12.92) + return torch.clamp(arr, 0, 1) diff --git a/torchstain/torch/utils/rgb2lab.py b/torchstain/torch/utils/rgb2lab.py new file mode 100644 index 0000000..1a2d9a8 --- /dev/null +++ b/torchstain/torch/utils/rgb2lab.py @@ -0,0 +1,44 @@ +import torch + +# constant conversion matrices between color spaces: https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 +_rgb2xyz = torch.tensor([[0.412453, 0.357580, 0.180423], + [0.212671, 0.715160, 0.072169], + [0.019334, 0.119193, 0.950227]]) + +_white = torch.tensor([0.95047, 1., 1.08883]) + +def rgb2lab(rgb): + arr = rgb.type(torch.float32) + + # convert rgb -> xyz color domain + mask = arr > 0.04045 + not_mask = torch.logical_not(mask) + arr.masked_scatter_(mask, torch.pow((arr[mask] + 0.055) / 1.055, 2.4)) + arr.masked_scatter_(not_mask, mask / 12.92) + + xyz = torch.tensordot(torch.t(_rgb2xyz), arr, dims=([0], [0])) + + # scale by CIE XYZ tristimulus values of the reference white point + arr = torch.mul(xyz, 1 / _white.unsqueeze(dim=-1).unsqueeze(dim=-1)) + + # nonlinear distortion and linear transformation + mask = arr > 0.008856 + not_mask = torch.logical_not(mask) + arr.masked_scatter_(mask, torch.pow(arr[mask], 1. / 3.)) + arr.masked_scatter_(not_mask, 7.787 * arr[~mask] + 16. / 166.) + + # get each channel as individual tensors + x, y, z = arr[0], arr[1], arr[2] + + # vector scaling + L = (116. * y) - 16. + a = 500.0 * (x - y) + b = 200.0 * (y - z) + + # OpenCV format + L *= 2.55 + a += 128 + b += 128 + + # finally, get LAB color domain + return torch.stack([L, a, b], axis=0) diff --git a/torchstain/torch/utils/split.py b/torchstain/torch/utils/split.py new file mode 100644 index 0000000..a9c0dda --- /dev/null +++ b/torchstain/torch/utils/split.py @@ -0,0 +1,15 @@ +import torch + +def csplit(I): + return [I[i] for i in range(I.shape[0])] + +def cmerge(I1, I2, I3): + return torch.stack([I1, I2, I3], dim=0) + +def lab_split(I): + I = I.type(torch.FloatTensor) + I1, I2, I3 = csplit(I) + return I1 / 2.55, I2 - 128, I3 - 128 + +def lab_merge(I1, I2, I3): + return cmerge(I1 * 2.55, I2 + 128, I3 + 128) diff --git a/torchstain/torch/utils/stats.py b/torchstain/torch/utils/stats.py new file mode 100644 index 0000000..0fa45bb --- /dev/null +++ b/torchstain/torch/utils/stats.py @@ -0,0 +1,7 @@ +import torch + +def get_mean_std(I): + return torch.mean(I), torch.std(I) + +def standardize(x, mu, std): + return (x - mu) / std From 18b539eb395b8eafd4f8bc5f5b56860f700c2fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Sat, 3 Dec 2022 23:57:51 +0100 Subject: [PATCH 04/19] torch reinhard almost working --- torchstain/torch/normalizers/__init__.py | 1 + torchstain/torch/normalizers/reinhard.py | 55 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 torchstain/torch/normalizers/reinhard.py diff --git a/torchstain/torch/normalizers/__init__.py b/torchstain/torch/normalizers/__init__.py index febcd90..c78c273 100644 --- a/torchstain/torch/normalizers/__init__.py +++ b/torchstain/torch/normalizers/__init__.py @@ -1 +1,2 @@ from torchstain.torch.normalizers.macenko import TorchMacenkoNormalizer +from torchstain.torch.normalizers.reinhard import TorchReinhardNormalizer diff --git a/torchstain/torch/normalizers/reinhard.py b/torchstain/torch/normalizers/reinhard.py new file mode 100644 index 0000000..0970764 --- /dev/null +++ b/torchstain/torch/normalizers/reinhard.py @@ -0,0 +1,55 @@ +import torch +from torchstain.base.normalizers import HENormalizer +from torchstain.torch.utils.rgb2lab import rgb2lab +from torchstain.torch.utils.lab2rgb import lab2rgb +from torchstain.torch.utils.split import csplit, cmerge, lab_split, lab_merge +from torchstain.torch.utils.stats import get_mean_std, standardize + +""" +Source code adapted from: +https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_normalization/reinhard.py +https://github.com/Peter554/StainTools/blob/master/staintools/reinhard_color_normalizer.py +""" +class TorchReinhardNormalizer(HENormalizer): + def __init__(self): + super().__init__() + self.target_mus = None + self.target_stds = None + + def fit(self, target): + # normalize + target = target.type(torch.float32) / 255 + + # convert to LAB + lab = rgb2lab(target) + + # get summary statistics + stack_ = torch.tensor([get_mean_std(x) for x in lab_split(lab)]) + self.target_means = stack_[:, 0] + self.target_stds = stack_[:, 1] + + def normalize(self, I): + # normalize + I = I.type(torch.float32) / 255 + + # convert to LAB + lab = rgb2lab(I) + labs = lab_split(lab) + + # get summary statistics from LAB + stack_ = torch.tensor([get_mean_std(x) for x in labs]) + mus = stack_[:, 0] + stds = stack_[:, 1] + + # standardize intensities channel-wise and normalize using target mus and stds + result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \ + in zip(labs, mus, stds, self.target_means, self.target_stds)] + + # rebuild LAB + lab = lab_merge(*result) + + # convert back to RGB from LAB + lab = lab2rgb(lab) + + # rescale to [0, 255] uint8 + return (lab * 255).type(torch.uint8) From ede14d71051d945bb42efa9a13243ecd907c851f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Sun, 4 Dec 2022 00:15:35 +0100 Subject: [PATCH 05/19] fixed bug - torch reinhard works --- torchstain/torch/utils/lab2rgb.py | 8 ++++---- torchstain/torch/utils/rgb2lab.py | 10 +++++----- torchstain/torch/utils/split.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/torchstain/torch/utils/lab2rgb.py b/torchstain/torch/utils/lab2rgb.py index 623be42..250c1c9 100644 --- a/torchstain/torch/utils/lab2rgb.py +++ b/torchstain/torch/utils/lab2rgb.py @@ -20,8 +20,8 @@ def lab2rgb(lab): # apply boolean transforms mask = out > 0.2068966 not_mask = torch.logical_not(mask) - out.masked_scatter_(mask, torch.pow(out, 3)) - out.masked_scatter_(not_mask, (out - 16 / 116) / 7.787) + out.masked_scatter_(mask, torch.pow(torch.masked_select(out, mask), 3)) + out.masked_scatter_(not_mask, (torch.masked_select(out, not_mask) - 16 / 116) / 7.787) # rescale to the reference white (illuminant) out = torch.mul(out, _white.type(out.dtype).unsqueeze(dim=-1).unsqueeze(dim=-1)) @@ -30,6 +30,6 @@ def lab2rgb(lab): arr = torch.tensordot(out, torch.t(_xyz2rgb).type(out.dtype), dims=([0], [0])) mask = arr > 0.0031308 not_mask = torch.logical_not(mask) - arr.masked_scatter(mask, 1.055 * torch.pow(arr, 1 / 2.4) - 0.055) - arr.masked_scatter(not_mask, arr * 12.92) + arr.masked_scatter_(mask, 1.055 * torch.pow(torch.masked_select(arr, mask), 1 / 2.4) - 0.055) + arr.masked_scatter_(not_mask, torch.masked_select(arr, not_mask) * 12.92) return torch.clamp(arr, 0, 1) diff --git a/torchstain/torch/utils/rgb2lab.py b/torchstain/torch/utils/rgb2lab.py index 1a2d9a8..3b1aa50 100644 --- a/torchstain/torch/utils/rgb2lab.py +++ b/torchstain/torch/utils/rgb2lab.py @@ -13,19 +13,19 @@ def rgb2lab(rgb): # convert rgb -> xyz color domain mask = arr > 0.04045 not_mask = torch.logical_not(mask) - arr.masked_scatter_(mask, torch.pow((arr[mask] + 0.055) / 1.055, 2.4)) - arr.masked_scatter_(not_mask, mask / 12.92) + arr.masked_scatter_(mask, torch.pow((torch.masked_select(arr, mask) + 0.055) / 1.055, 2.4)) + arr.masked_scatter_(not_mask, torch.masked_select(arr, not_mask) / 12.92) xyz = torch.tensordot(torch.t(_rgb2xyz), arr, dims=([0], [0])) # scale by CIE XYZ tristimulus values of the reference white point - arr = torch.mul(xyz, 1 / _white.unsqueeze(dim=-1).unsqueeze(dim=-1)) + arr = torch.mul(xyz, 1 / _white.type(xyz.dtype).unsqueeze(dim=-1).unsqueeze(dim=-1)) # nonlinear distortion and linear transformation mask = arr > 0.008856 not_mask = torch.logical_not(mask) - arr.masked_scatter_(mask, torch.pow(arr[mask], 1. / 3.)) - arr.masked_scatter_(not_mask, 7.787 * arr[~mask] + 16. / 166.) + arr.masked_scatter_(mask, torch.pow(torch.masked_select(arr, mask), 1 / 3)) + arr.masked_scatter_(not_mask, 7.787 * torch.masked_select(arr, not_mask) + 16 / 166) # get each channel as individual tensors x, y, z = arr[0], arr[1], arr[2] diff --git a/torchstain/torch/utils/split.py b/torchstain/torch/utils/split.py index a9c0dda..d6f6fdb 100644 --- a/torchstain/torch/utils/split.py +++ b/torchstain/torch/utils/split.py @@ -7,7 +7,7 @@ def cmerge(I1, I2, I3): return torch.stack([I1, I2, I3], dim=0) def lab_split(I): - I = I.type(torch.FloatTensor) + I = I.type(torch.float32) I1, I2, I3 = csplit(I) return I1 / 2.55, I2 - 128, I3 - 128 From 51f944825fff19551a7ca3580f04651b49bd5952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Sun, 4 Dec 2022 00:19:07 +0100 Subject: [PATCH 06/19] updated README as all backends are supported with Reinhard --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9047b49..d16b5ea 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ GPU-accelerated stain normalization tools for histopathological images. Compatib Normalization algorithms currently implemented: - Macenko et al. [\[1\]](#reference) (ported from [numpy implementation](https://github.com/schaugf/HEnorm_python)) -- Reinhard et al. [\[2\]](#reference) (only numpy & TensorFlow backend support) +- Reinhard et al. [\[2\]](#reference) ## Installation @@ -49,7 +49,7 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) | Algorithm | numpy | torch | tensorflow | |-|-|-|-| | Macenko | ✓ | ✓ | ✓ | -| Reinhard | ✓ | ✗ | ✓ | +| Reinhard | ✓ | ✓ | ✓ | ## Backend comparison From 3af74774838a907a9d8491b65242559927cd1a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Sun, 4 Dec 2022 00:26:41 +0100 Subject: [PATCH 07/19] added reinhard test for all backends --- tests/test_tf.py | 30 +++++++++++++++++++++++++++++- tests/test_torch.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/tests/test_tf.py b/tests/test_tf.py index b24757a..6098e37 100644 --- a/tests/test_tf.py +++ b/tests/test_tf.py @@ -22,7 +22,7 @@ def test_percentile(): np.testing.assert_almost_equal(p_np, p_t) -def test_normalize_tf(): +def test_macenko_tf(): size = 1024 curr_file_path = os.path.dirname(os.path.realpath(__file__)) target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size)) @@ -49,3 +49,31 @@ def test_normalize_tf(): # assess whether the normalized images are identical across backends np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_tf.flatten()), 1.0, decimal=4, verbose=True) + +def test_reinhard_tf(): + size = 1024 + curr_file_path = os.path.dirname(os.path.realpath(__file__)) + target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size)) + to_transform = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/source.png")), cv2.COLOR_BGR2RGB), (size, size)) + + # setup preprocessing and preprocess image to be normalized + T = lambda x: tf.convert_to_tensor(x, dtype=tf.float32) + t_to_transform = T(to_transform) + + # initialize normalizers for each backend and fit to target image + normalizer = torchstain.normalizers.ReinhardNormalizer(backend='numpy') + normalizer.fit(target) + + tf_normalizer = torchstain.normalizers.ReinhardNormalizer(backend='tensorflow') + tf_normalizer.fit(T(target)) + + # transform + result_numpy = normalizer.normalize(I=to_transform) + result_tf = tf_normalizer.normalize(I=t_to_transform) + + # convert to numpy and set dtype + result_numpy = result_numpy.astype("float32") + result_tf = result_tf.numpy().astype("float32") + + # assess whether the normalized images are identical across backends + np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_tf.flatten()), 1.0, decimal=4, verbose=True) diff --git a/tests/test_torch.py b/tests/test_torch.py index 74b6fd2..0a7e99a 100644 --- a/tests/test_torch.py +++ b/tests/test_torch.py @@ -27,7 +27,7 @@ def test_percentile(): np.testing.assert_almost_equal(p_np, p_t) -def test_normalize_torch(): +def test_macenko_torch(): size = 1024 curr_file_path = os.path.dirname(os.path.realpath(__file__)) target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size)) @@ -36,7 +36,7 @@ def test_normalize_torch(): # setup preprocessing and preprocess image to be normalized T = transforms.Compose([ transforms.ToTensor(), - transforms.Lambda(lambda x: x*255) + transforms.Lambda(lambda x: x * 255) ]) t_to_transform = T(to_transform) @@ -57,3 +57,34 @@ def test_normalize_torch(): # assess whether the normalized images are identical across backends np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_torch.flatten()), 1.0, decimal=4, verbose=True) + +def test_reinhard_torch(): + size = 1024 + curr_file_path = os.path.dirname(os.path.realpath(__file__)) + target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size)) + to_transform = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/source.png")), cv2.COLOR_BGR2RGB), (size, size)) + + # setup preprocessing and preprocess image to be normalized + T = transforms.Compose([ + transforms.ToTensor(), + transforms.Lambda(lambda x: x * 255) + ]) + t_to_transform = T(to_transform) + + # initialize normalizers for each backend and fit to target image + normalizer = torchstain.normalizers.ReinhardNormalizer(backend='numpy') + normalizer.fit(target) + + torch_normalizer = torchstain.normalizers.ReinhardNormalizer(backend='torch') + torch_normalizer.fit(T(target)) + + # transform + result_numpy = normalizer.normalize(I=to_transform) + result_torch = torch_normalizer.normalize(I=t_to_transform) + + # convert to numpy and set dtype + result_numpy = result_numpy.astype("float32") + result_torch = result_torch.numpy().astype("float32") + + # assess whether the normalized images are identical across backends + np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_torch.flatten()), 1.0, decimal=4, verbose=True) From 17c2d2886027a34ecf190c199fc8bcc7ec3536d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Fri, 23 Dec 2022 00:01:17 +0100 Subject: [PATCH 08/19] added support for modified reinhard, all backends --- torchstain/base/normalizers/reinhard.py | 8 ++++---- torchstain/numpy/normalizers/reinhard.py | 26 ++++++++++++++++++++---- torchstain/tf/normalizers/reinhard.py | 26 ++++++++++++++++++++---- torchstain/torch/normalizers/reinhard.py | 26 ++++++++++++++++++++---- 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/torchstain/base/normalizers/reinhard.py b/torchstain/base/normalizers/reinhard.py index f96f087..7800933 100644 --- a/torchstain/base/normalizers/reinhard.py +++ b/torchstain/base/normalizers/reinhard.py @@ -1,12 +1,12 @@ -def ReinhardNormalizer(backend='numpy'): +def ReinhardNormalizer(backend='numpy', method=None): if backend == 'numpy': from torchstain.numpy.normalizers import NumpyReinhardNormalizer - return NumpyReinhardNormalizer() + return NumpyReinhardNormalizer(method=method) elif backend == "torch": from torchstain.torch.normalizers import TorchReinhardNormalizer - return TorchReinhardNormalizer() + return TorchReinhardNormalizer(method=method) elif backend == "tensorflow": from torchstain.tf.normalizers import TensorFlowReinhardNormalizer - return TensorFlowReinhardNormalizer() + return TensorFlowReinhardNormalizer(method=method) else: raise Exception(f'Unknown backend {backend}') diff --git a/torchstain/numpy/normalizers/reinhard.py b/torchstain/numpy/normalizers/reinhard.py index fbb8d13..d299dd3 100644 --- a/torchstain/numpy/normalizers/reinhard.py +++ b/torchstain/numpy/normalizers/reinhard.py @@ -11,8 +11,9 @@ https://github.com/Peter554/StainTools/blob/master/staintools/reinhard_color_normalizer.py """ class NumpyReinhardNormalizer(HENormalizer): - def __init__(self): + def __init__(self, method=None): super().__init__() + self.method = method self.target_mus = None self.target_stds = None @@ -41,9 +42,26 @@ def normalize(self, I): mus = stack_[:, 0] stds = stack_[:, 1] - # standardize intensities channel-wise and normalize using target mus and stds - result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \ - in zip(labs, mus, stds, self.target_means, self.target_stds)] + # normalize + if self.method is None: + # standardize intensities channel-wise and normalize using target mus and stds + result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \ + in zip(labs, mus, stds, self.target_means, self.target_stds)] + + elif self.method == "modified": + # calculate q + q = (self.target_stds[0] - stds[0]) / self.target_stds[0] + q = 0.05 if q <= 0 else q + + # normalize each channel independently + l_norm = mus[0] + (labs[0] - mus[0]) * (1 + q) + a_norm = self.target_means[1] + (labs[1] - mus[1]) + b_norm = self.target_means[2] + (labs[2] - mus[2]) + + result = [l_norm, a_norm, b_norm] + + else: + raise ValueError("Unsupported 'method' was chosen. Choose either {None, 'modified'}.") # rebuild LAB lab = lab_merge(*result) diff --git a/torchstain/tf/normalizers/reinhard.py b/torchstain/tf/normalizers/reinhard.py index 8a2b601..977f084 100644 --- a/torchstain/tf/normalizers/reinhard.py +++ b/torchstain/tf/normalizers/reinhard.py @@ -11,8 +11,9 @@ https://github.com/Peter554/StainTools/blob/master/staintools/reinhard_color_normalizer.py """ class TensorFlowReinhardNormalizer(HENormalizer): - def __init__(self): + def __init__(self, method=None): super().__init__() + self.method = method self.target_mus = None self.target_stds = None @@ -41,9 +42,26 @@ def normalize(self, I): mus = stack_[:, 0] stds = stack_[:, 1] - # standardize intensities channel-wise and normalize using target mus and stds - result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \ - in zip(labs, mus, stds, self.target_means, self.target_stds)] + # normalize + if self.method is None: + # standardize intensities channel-wise and normalize using target mus and stds + result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \ + in zip(labs, mus, stds, self.target_means, self.target_stds)] + + elif self.method == "modified": + # calculate q + q = (self.target_stds[0] - stds[0]) / self.target_stds[0] + q = 0.05 if q <= 0 else q + + # normalize each channel independently + l_norm = mus[0] + (labs[0] - mus[0]) * (1 + q) + a_norm = self.target_means[1] + (labs[1] - mus[1]) + b_norm = self.target_means[2] + (labs[2] - mus[2]) + + result = [l_norm, a_norm, b_norm] + + else: + raise ValueError("Unsupported 'method' was chosen. Choose either {None, 'modified'}.") # rebuild LAB lab = lab_merge(*result) diff --git a/torchstain/torch/normalizers/reinhard.py b/torchstain/torch/normalizers/reinhard.py index 0970764..dccc63f 100644 --- a/torchstain/torch/normalizers/reinhard.py +++ b/torchstain/torch/normalizers/reinhard.py @@ -11,8 +11,9 @@ https://github.com/Peter554/StainTools/blob/master/staintools/reinhard_color_normalizer.py """ class TorchReinhardNormalizer(HENormalizer): - def __init__(self): + def __init__(self, method=None): super().__init__() + self.method = method self.target_mus = None self.target_stds = None @@ -41,9 +42,26 @@ def normalize(self, I): mus = stack_[:, 0] stds = stack_[:, 1] - # standardize intensities channel-wise and normalize using target mus and stds - result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \ - in zip(labs, mus, stds, self.target_means, self.target_stds)] + # normalize + if self.method is None: + # standardize intensities channel-wise and normalize using target mus and stds + result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \ + in zip(labs, mus, stds, self.target_means, self.target_stds)] + + elif self.method == "modified": + # calculate q + q = (self.target_stds[0] - stds[0]) / self.target_stds[0] + q = 0.05 if q <= 0 else q + + # normalize each channel independently + l_norm = mus[0] + (labs[0] - mus[0]) * (1 + q) + a_norm = self.target_means[1] + (labs[1] - mus[1]) + b_norm = self.target_means[2] + (labs[2] - mus[2]) + + result = [l_norm, a_norm, b_norm] + + else: + raise ValueError("Unsupported 'method' was chosen. Choose either {None, 'modified'}.") # rebuild LAB lab = lab_merge(*result) From afe810c9eb4d9d083453769a334ffbc8af3cf8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Fri, 23 Dec 2022 00:19:03 +0100 Subject: [PATCH 09/19] updated README regarding modified reinhard support --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d16b5ea..74af142 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ GPU-accelerated stain normalization tools for histopathological images. Compatible with PyTorch, TensorFlow, and Numpy. Normalization algorithms currently implemented: -- Macenko et al. [\[1\]](#reference) (ported from [numpy implementation](https://github.com/schaugf/HEnorm_python)) -- Reinhard et al. [\[2\]](#reference) +- Macenko [\[1\]](#reference) (ported from [numpy implementation](https://github.com/schaugf/HEnorm_python)) +- Reinhard [\[2\]](#reference) +- Modified Reinhard [\[3\]](#reference) ## Installation @@ -50,6 +51,7 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) |-|-|-|-| | Macenko | ✓ | ✓ | ✓ | | Reinhard | ✓ | ✓ | ✓ | +| Modified Reinhard | ✓ | ✓ | ✓ | ## Backend comparison @@ -68,8 +70,9 @@ Results with 10 runs per size on a Intel(R) Core(TM) i5-8365U CPU @ 1.60GHz ## Reference -- [1] Macenko, Marc, et al. "A method for normalizing histology slides for quantitative analysis." 2009 IEEE International Symposium on Biomedical Imaging: From Nano to Macro. IEEE, 2009. -- [2] Reinhard, Erik, et al. "Color transfer between images." IEEE Computer Graphics and Applications. IEEE, 2001. +- [1] Macenko, Marc et al. "A method for normalizing histology slides for quantitative analysis." 2009 IEEE International Symposium on Biomedical Imaging: From Nano to Macro. IEEE, 2009. +- [2] Reinhard, Erik et al. "Color transfer between images." IEEE Computer Graphics and Applications. IEEE, 2001. +- [3] Roy, Santanu et al. "Modified Reinhard Algorithm for Color Normalization of Colorectal Cancer Histopathology Images". 2021 29th European Signal Processing Conference (EUSIPCO), IEEE, 2021. ## Citing From f14df82dc5c01cf5e6d834ded378b1af9b484c7b Mon Sep 17 00:00:00 2001 From: Raphael Attias Date: Wed, 11 Jan 2023 14:00:33 -0500 Subject: [PATCH 10/19] Removed deprecated torch.lstsq, replaced by torch.linalg.lstsq --- .vscode/launch.json | 16 ++++++++++++++++ example.py | 3 --- torchstain/torch/normalizers/macenko.py | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..534cb81 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python : fichier actif", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/example.py b/example.py index 6de0f36..7a733f2 100644 --- a/example.py +++ b/example.py @@ -21,8 +21,6 @@ torch_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='torch') torch_normalizer.fit(T(target)) -tf_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='tensorflow') -tf_normalizer.fit(T(target)) t_to_transform = T(to_transform) @@ -81,7 +79,6 @@ plt.show() t_ = time.time() -norm, H, E = tf_normalizer.normalize(I=t_to_transform, stains=True) print("tf runtime:", time.time() - t_) plt.figure() diff --git a/torchstain/torch/normalizers/macenko.py b/torchstain/torch/normalizers/macenko.py index 710b7e3..c3917a5 100644 --- a/torchstain/torch/normalizers/macenko.py +++ b/torchstain/torch/normalizers/macenko.py @@ -49,7 +49,7 @@ def __find_concentration(self, OD, HE): Y = OD.T # determine concentrations of the individual stains - return torch.lstsq(Y, HE)[0][:2] + return torch.linalg.lstsq(HE, Y)[0] def __compute_matrices(self, I, Io, alpha, beta): OD, ODhat = self.__convert_rgb2od(I, Io=Io, beta=beta) From 6883215ebc4d144bbe5788d4aa8405ad219001f1 Mon Sep 17 00:00:00 2001 From: Raphael Attias Date: Wed, 11 Jan 2023 15:19:41 -0500 Subject: [PATCH 11/19] removed .vscode, revert change to example.py and updated min version of Torch to 1.9 --- .github/workflows/tests_full.yml | 2 +- .vscode/launch.json | 16 ---------------- example.py | 4 +++- 3 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index e550c88..ee9cb06 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -73,7 +73,7 @@ jobs: matrix: os: [ windows-2019, ubuntu-18.04, macos-11 ] python-version: [ 3.6, 3.7, 3.8, 3.9 ] - pytorch-version: [1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0] + pytorch-version: [1.9.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0] exclude: - python-version: 3.6 pytorch-version: 1.11.0 diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 534cb81..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. - // Pointez pour afficher la description des attributs existants. - // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python : fichier actif", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal", - "justMyCode": true - } - ] -} \ No newline at end of file diff --git a/example.py b/example.py index 7a733f2..1208721 100644 --- a/example.py +++ b/example.py @@ -21,6 +21,8 @@ torch_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='torch') torch_normalizer.fit(T(target)) +tf_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='tensorflow') +tf_normalizer.fit(T(target)) t_to_transform = T(to_transform) @@ -80,7 +82,7 @@ t_ = time.time() print("tf runtime:", time.time() - t_) - +norm, H, E = tf_normalizer.normalize(I=t_to_transform, stains=True) plt.figure() plt.suptitle('tensorflow normalizer') plt.subplot(2, 2, 1) From 26091e408f3f96c20baec78ef64d3cc8250083ba Mon Sep 17 00:00:00 2001 From: Raphael Attias Date: Wed, 11 Jan 2023 15:24:11 -0500 Subject: [PATCH 12/19] fix example --- example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example.py b/example.py index 1208721..98ba5b9 100644 --- a/example.py +++ b/example.py @@ -81,8 +81,8 @@ plt.show() t_ = time.time() -print("tf runtime:", time.time() - t_) norm, H, E = tf_normalizer.normalize(I=t_to_transform, stains=True) +print("tf runtime:", time.time() - t_) plt.figure() plt.suptitle('tensorflow normalizer') plt.subplot(2, 2, 1) From c5b715ca6f5df605cf2381a4becaa40112a0b20e Mon Sep 17 00:00:00 2001 From: Raphael Attias Date: Wed, 11 Jan 2023 15:42:10 -0500 Subject: [PATCH 13/19] add torch version checking for backward compatibility --- .github/workflows/tests_full.yml | 2 +- torchstain/torch/normalizers/macenko.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index ee9cb06..0865876 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -73,7 +73,7 @@ jobs: matrix: os: [ windows-2019, ubuntu-18.04, macos-11 ] python-version: [ 3.6, 3.7, 3.8, 3.9 ] - pytorch-version: [1.9.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0] + pytorch-version: [1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0] exclude: - python-version: 3.6 pytorch-version: 1.11.0 diff --git a/torchstain/torch/normalizers/macenko.py b/torchstain/torch/normalizers/macenko.py index c3917a5..43c0c5f 100644 --- a/torchstain/torch/normalizers/macenko.py +++ b/torchstain/torch/normalizers/macenko.py @@ -49,7 +49,11 @@ def __find_concentration(self, OD, HE): Y = OD.T # determine concentrations of the individual stains - return torch.linalg.lstsq(HE, Y)[0] + + if torch.__version__ >= (1,9,0): + return torch.linalg.lstsq(HE, Y)[0] + else: + return torch.lstsq(Y, HE)[0][:2] def __compute_matrices(self, I, Io, alpha, beta): OD, ODhat = self.__convert_rgb2od(I, Io=Io, beta=beta) From fe23f2595a0dabe03ac3e0b25e34b3a3e6871629 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 17 Jan 2023 13:42:02 +0100 Subject: [PATCH 14/19] use python float instead of np.float --- torchstain/numpy/normalizers/macenko.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/torchstain/numpy/normalizers/macenko.py b/torchstain/numpy/normalizers/macenko.py index 37dac55..faada97 100644 --- a/torchstain/numpy/normalizers/macenko.py +++ b/torchstain/numpy/normalizers/macenko.py @@ -16,7 +16,7 @@ def __init__(self): def __convert_rgb2od(self, I, Io=240, beta=0.15): # calculate optical density - OD = -np.log((I.astype(np.float)+1)/Io) + OD = -np.log((I.astype(float)+1)/Io) # remove transparent pixels ODhat = OD[~np.any(OD < beta, axis=1)] From 2b4dd90057beef28a820cae963e22ddf96ec84e6 Mon Sep 17 00:00:00 2001 From: Raphael Attias Date: Tue, 17 Jan 2023 09:55:42 -0500 Subject: [PATCH 15/19] Added torch version check in the __init__ Updated eigen vectors solver --- torchstain/torch/normalizers/macenko.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/torchstain/torch/normalizers/macenko.py b/torchstain/torch/normalizers/macenko.py index 43c0c5f..b04cd28 100644 --- a/torchstain/torch/normalizers/macenko.py +++ b/torchstain/torch/normalizers/macenko.py @@ -14,7 +14,8 @@ def __init__(self): [0.7201, 0.8012], [0.4062, 0.5581]]) self.maxCRef = torch.tensor([1.9705, 1.0308]) - + self.deprecated_torch = torch.__version__ < (1,9,0) + def __convert_rgb2od(self, I, Io, beta): I = I.permute(1, 2, 0) @@ -49,17 +50,16 @@ def __find_concentration(self, OD, HE): Y = OD.T # determine concentrations of the individual stains - - if torch.__version__ >= (1,9,0): - return torch.linalg.lstsq(HE, Y)[0] - else: + if self.deprecated_torch: return torch.lstsq(Y, HE)[0][:2] + + return torch.linalg.lstsq(HE, Y)[0] def __compute_matrices(self, I, Io, alpha, beta): OD, ODhat = self.__convert_rgb2od(I, Io=Io, beta=beta) # compute eigenvectors - _, eigvecs = torch.symeig(cov(ODhat.T), eigenvectors=True) + _, eigvecs = torch.linalg.eigh(cov(ODhat.T)) eigvecs = eigvecs[:, [1, 2]] HE = self.__find_HE(ODhat, eigvecs, alpha) From dec54293a158b8ad8a8481f70092a1ca9d51b864 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Thu, 2 Mar 2023 10:41:49 +0100 Subject: [PATCH 16/19] fix pytorch/python incompatibility --- .github/workflows/tests_full.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 0865876..3499e72 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -79,6 +79,8 @@ jobs: pytorch-version: 1.11.0 - python-version: 3.6 pytorch-version: 1.12.0 + - python-version: 3.6 + pytorch-version: 1.13.0 steps: - uses: actions/checkout@v1 From 13a338c6771b9c528203f72f63b1310432b436ef Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Thu, 2 Mar 2023 10:56:33 +0100 Subject: [PATCH 17/19] check for torch.linalg.lstsq instead of inconsistent torch.__version__ --- torchstain/torch/normalizers/macenko.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/torchstain/torch/normalizers/macenko.py b/torchstain/torch/normalizers/macenko.py index b04cd28..74d5a00 100644 --- a/torchstain/torch/normalizers/macenko.py +++ b/torchstain/torch/normalizers/macenko.py @@ -14,7 +14,9 @@ def __init__(self): [0.7201, 0.8012], [0.4062, 0.5581]]) self.maxCRef = torch.tensor([1.9705, 1.0308]) - self.deprecated_torch = torch.__version__ < (1,9,0) + + # Avoid using deprecated torch.lstsq (since 1.9.0) + self.updated_lstsq = hasattr(torch.linalg, 'lstsq') def __convert_rgb2od(self, I, Io, beta): I = I.permute(1, 2, 0) @@ -50,7 +52,7 @@ def __find_concentration(self, OD, HE): Y = OD.T # determine concentrations of the individual stains - if self.deprecated_torch: + if not self.updated_lstsq: return torch.lstsq(Y, HE)[0][:2] return torch.linalg.lstsq(HE, Y)[0] From 602b5b18dba674b0cff413b69b0d73b182bcc34d Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Thu, 2 Mar 2023 11:38:49 +0100 Subject: [PATCH 18/19] remove ssim from tests --- tests/test_tf.py | 14 ++++++-------- tests/test_torch.py | 16 ++++++++-------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/test_tf.py b/tests/test_tf.py index 6098e37..8edb65a 100644 --- a/tests/test_tf.py +++ b/tests/test_tf.py @@ -3,8 +3,6 @@ import torchstain import torchstain.tf import tensorflow as tf -import time -from skimage.metrics import structural_similarity as ssim import numpy as np def test_cov(): @@ -44,11 +42,11 @@ def test_macenko_tf(): result_tf, _, _ = tf_normalizer.normalize(I=t_to_transform, stains=True) # convert to numpy and set dtype - result_numpy = result_numpy.astype("float32") - result_tf = result_tf.numpy().astype("float32") + result_numpy = result_numpy.astype("float32") / 255. + result_tf = result_tf.numpy().astype("float32") / 255. # assess whether the normalized images are identical across backends - np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_tf.flatten()), 1.0, decimal=4, verbose=True) + np.testing.assert_almost_equal(result_numpy.flatten(), result_tf.flatten(), decimal=2, verbose=True) def test_reinhard_tf(): size = 1024 @@ -72,8 +70,8 @@ def test_reinhard_tf(): result_tf = tf_normalizer.normalize(I=t_to_transform) # convert to numpy and set dtype - result_numpy = result_numpy.astype("float32") - result_tf = result_tf.numpy().astype("float32") + result_numpy = result_numpy.astype("float32") / 255. + result_tf = result_tf.numpy().astype("float32") / 255. # assess whether the normalized images are identical across backends - np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_tf.flatten()), 1.0, decimal=4, verbose=True) + np.testing.assert_almost_equal(result_numpy.flatten(), result_tf.flatten(), decimal=2, verbose=True) diff --git a/tests/test_torch.py b/tests/test_torch.py index 0a7e99a..eb7b60c 100644 --- a/tests/test_torch.py +++ b/tests/test_torch.py @@ -4,10 +4,9 @@ import torchstain.torch import torch import torchvision -import time import numpy as np from torchvision import transforms -from skimage.metrics import structural_similarity as ssim + def setup_function(fn): print("torch version:", torch.__version__, "torchvision version:", torchvision.__version__) @@ -52,11 +51,11 @@ def test_macenko_torch(): result_torch, _, _ = torch_normalizer.normalize(I=t_to_transform, stains=True) # convert to numpy and set dtype - result_numpy = result_numpy.astype("float32") - result_torch = result_torch.numpy().astype("float32") + result_numpy = result_numpy.astype("float32") / 255. + result_torch = result_torch.numpy().astype("float32") / 255. # assess whether the normalized images are identical across backends - np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_torch.flatten()), 1.0, decimal=4, verbose=True) + np.testing.assert_almost_equal(result_numpy.flatten(), result_torch.flatten(), decimal=2, verbose=True) def test_reinhard_torch(): size = 1024 @@ -83,8 +82,9 @@ def test_reinhard_torch(): result_torch = torch_normalizer.normalize(I=t_to_transform) # convert to numpy and set dtype - result_numpy = result_numpy.astype("float32") - result_torch = result_torch.numpy().astype("float32") + result_numpy = result_numpy.astype("float32") / 255. + result_torch = result_torch.numpy().astype("float32") / 255. + # assess whether the normalized images are identical across backends - np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_torch.flatten()), 1.0, decimal=4, verbose=True) + np.testing.assert_almost_equal(result_numpy.flatten(), result_torch.flatten(), decimal=2, verbose=True) From 435b5475921edbdde4b05fb3acd6d0d02b4d1162 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Thu, 2 Mar 2023 13:58:36 +0100 Subject: [PATCH 19/19] bump version to 1.3.0 --- setup.py | 2 +- torchstain/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index d27acf7..a8b3628 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='torchstain', - version='1.2.0', + version='1.3.0', description='Stain normalization tools for histological analysis and computational pathology', long_description=README, long_description_content_type='text/markdown', diff --git a/torchstain/__init__.py b/torchstain/__init__.py index dab8618..4b11e31 100644 --- a/torchstain/__init__.py +++ b/torchstain/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.2.0' +__version__ = '1.3.0' from torchstain.base import normalizers \ No newline at end of file