Skip to content

Commit 80fb609

Browse files
Merge pull request #34 from EIDOSLAB/development
Draft release v1.3.0 rc1
2 parents c73860c + 64622cd commit 80fb609

File tree

19 files changed

+308
-28
lines changed

19 files changed

+308
-28
lines changed

.github/workflows/tests_full.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ jobs:
7373
matrix:
7474
os: [ windows-2019, ubuntu-18.04, macos-11 ]
7575
python-version: [ 3.6, 3.7, 3.8, 3.9 ]
76-
pytorch-version: [1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0]
76+
pytorch-version: [1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0]
7777
exclude:
7878
- python-version: 3.6
7979
pytorch-version: 1.11.0

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
GPU-accelerated stain normalization tools for histopathological images. Compatible with PyTorch, TensorFlow, and Numpy.
99
Normalization algorithms currently implemented:
1010

11-
- Macenko et al. [\[1\]](#reference) (ported from [numpy implementation](https://github.com/schaugf/HEnorm_python))
12-
- Reinhard et al. [\[2\]](#reference) (only numpy & TensorFlow backend support)
11+
- Macenko [\[1\]](#reference) (ported from [numpy implementation](https://github.com/schaugf/HEnorm_python))
12+
- Reinhard [\[2\]](#reference)
13+
- Modified Reinhard [\[3\]](#reference)
1314

1415
## Installation
1516

@@ -49,7 +50,8 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True)
4950
| Algorithm | numpy | torch | tensorflow |
5051
|-|-|-|-|
5152
| Macenko | ✓ | ✓ | ✓ |
52-
| Reinhard | ✓ | ✗ | ✓ |
53+
| Reinhard | ✓ | ✓ | ✓ |
54+
| Modified Reinhard | ✓ | ✓ | ✓ |
5355

5456
## Backend comparison
5557

@@ -68,8 +70,9 @@ Results with 10 runs per size on a Intel(R) Core(TM) i5-8365U CPU @ 1.60GHz
6870

6971
## Reference
7072

71-
- [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.
72-
- [2] Reinhard, Erik, et al. "Color transfer between images." IEEE Computer Graphics and Applications. IEEE, 2001.
73+
- [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.
74+
- [2] Reinhard, Erik et al. "Color transfer between images." IEEE Computer Graphics and Applications. IEEE, 2001.
75+
- [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.
7376

7477
## Citing
7578

example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@
8383
t_ = time.time()
8484
norm, H, E = tf_normalizer.normalize(I=t_to_transform, stains=True)
8585
print("tf runtime:", time.time() - t_)
86-
8786
plt.figure()
8887
plt.suptitle('tensorflow normalizer')
8988
plt.subplot(2, 2, 1)

tests/test_tf.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_percentile():
2222

2323
np.testing.assert_almost_equal(p_np, p_t)
2424

25-
def test_normalize_tf():
25+
def test_macenko_tf():
2626
size = 1024
2727
curr_file_path = os.path.dirname(os.path.realpath(__file__))
2828
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():
4949

5050
# assess whether the normalized images are identical across backends
5151
np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_tf.flatten()), 1.0, decimal=4, verbose=True)
52+
53+
def test_reinhard_tf():
54+
size = 1024
55+
curr_file_path = os.path.dirname(os.path.realpath(__file__))
56+
target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size))
57+
to_transform = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/source.png")), cv2.COLOR_BGR2RGB), (size, size))
58+
59+
# setup preprocessing and preprocess image to be normalized
60+
T = lambda x: tf.convert_to_tensor(x, dtype=tf.float32)
61+
t_to_transform = T(to_transform)
62+
63+
# initialize normalizers for each backend and fit to target image
64+
normalizer = torchstain.normalizers.ReinhardNormalizer(backend='numpy')
65+
normalizer.fit(target)
66+
67+
tf_normalizer = torchstain.normalizers.ReinhardNormalizer(backend='tensorflow')
68+
tf_normalizer.fit(T(target))
69+
70+
# transform
71+
result_numpy = normalizer.normalize(I=to_transform)
72+
result_tf = tf_normalizer.normalize(I=t_to_transform)
73+
74+
# convert to numpy and set dtype
75+
result_numpy = result_numpy.astype("float32")
76+
result_tf = result_tf.numpy().astype("float32")
77+
78+
# assess whether the normalized images are identical across backends
79+
np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_tf.flatten()), 1.0, decimal=4, verbose=True)

tests/test_torch.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def test_percentile():
2727

2828
np.testing.assert_almost_equal(p_np, p_t)
2929

30-
def test_normalize_torch():
30+
def test_macenko_torch():
3131
size = 1024
3232
curr_file_path = os.path.dirname(os.path.realpath(__file__))
3333
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():
3636
# setup preprocessing and preprocess image to be normalized
3737
T = transforms.Compose([
3838
transforms.ToTensor(),
39-
transforms.Lambda(lambda x: x*255)
39+
transforms.Lambda(lambda x: x * 255)
4040
])
4141
t_to_transform = T(to_transform)
4242

@@ -57,3 +57,34 @@ def test_normalize_torch():
5757

5858
# assess whether the normalized images are identical across backends
5959
np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_torch.flatten()), 1.0, decimal=4, verbose=True)
60+
61+
def test_reinhard_torch():
62+
size = 1024
63+
curr_file_path = os.path.dirname(os.path.realpath(__file__))
64+
target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size))
65+
to_transform = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/source.png")), cv2.COLOR_BGR2RGB), (size, size))
66+
67+
# setup preprocessing and preprocess image to be normalized
68+
T = transforms.Compose([
69+
transforms.ToTensor(),
70+
transforms.Lambda(lambda x: x * 255)
71+
])
72+
t_to_transform = T(to_transform)
73+
74+
# initialize normalizers for each backend and fit to target image
75+
normalizer = torchstain.normalizers.ReinhardNormalizer(backend='numpy')
76+
normalizer.fit(target)
77+
78+
torch_normalizer = torchstain.normalizers.ReinhardNormalizer(backend='torch')
79+
torch_normalizer.fit(T(target))
80+
81+
# transform
82+
result_numpy = normalizer.normalize(I=to_transform)
83+
result_torch = torch_normalizer.normalize(I=t_to_transform)
84+
85+
# convert to numpy and set dtype
86+
result_numpy = result_numpy.astype("float32")
87+
result_torch = result_torch.numpy().astype("float32")
88+
89+
# assess whether the normalized images are identical across backends
90+
np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_torch.flatten()), 1.0, decimal=4, verbose=True)
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
def ReinhardNormalizer(backend='numpy'):
1+
def ReinhardNormalizer(backend='numpy', method=None):
22
if backend == 'numpy':
33
from torchstain.numpy.normalizers import NumpyReinhardNormalizer
4-
return NumpyReinhardNormalizer()
4+
return NumpyReinhardNormalizer(method=method)
55
elif backend == "torch":
6-
raise NotImplementedError
6+
from torchstain.torch.normalizers import TorchReinhardNormalizer
7+
return TorchReinhardNormalizer(method=method)
78
elif backend == "tensorflow":
89
from torchstain.tf.normalizers import TensorFlowReinhardNormalizer
9-
return TensorFlowReinhardNormalizer()
10+
return TensorFlowReinhardNormalizer(method=method)
1011
else:
1112
raise Exception(f'Unknown backend {backend}')

torchstain/numpy/normalizers/macenko.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def __init__(self):
1616

1717
def __convert_rgb2od(self, I, Io=240, beta=0.15):
1818
# calculate optical density
19-
OD = -np.log((I.astype(np.float)+1)/Io)
19+
OD = -np.log((I.astype(float)+1)/Io)
2020

2121
# remove transparent pixels
2222
ODhat = OD[~np.any(OD < beta, axis=1)]

torchstain/numpy/normalizers/reinhard.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
https://github.com/Peter554/StainTools/blob/master/staintools/reinhard_color_normalizer.py
1212
"""
1313
class NumpyReinhardNormalizer(HENormalizer):
14-
def __init__(self):
14+
def __init__(self, method=None):
1515
super().__init__()
16+
self.method = method
1617
self.target_mus = None
1718
self.target_stds = None
1819

@@ -41,9 +42,26 @@ def normalize(self, I):
4142
mus = stack_[:, 0]
4243
stds = stack_[:, 1]
4344

44-
# standardize intensities channel-wise and normalize using target mus and stds
45-
result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \
46-
in zip(labs, mus, stds, self.target_means, self.target_stds)]
45+
# normalize
46+
if self.method is None:
47+
# standardize intensities channel-wise and normalize using target mus and stds
48+
result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \
49+
in zip(labs, mus, stds, self.target_means, self.target_stds)]
50+
51+
elif self.method == "modified":
52+
# calculate q
53+
q = (self.target_stds[0] - stds[0]) / self.target_stds[0]
54+
q = 0.05 if q <= 0 else q
55+
56+
# normalize each channel independently
57+
l_norm = mus[0] + (labs[0] - mus[0]) * (1 + q)
58+
a_norm = self.target_means[1] + (labs[1] - mus[1])
59+
b_norm = self.target_means[2] + (labs[2] - mus[2])
60+
61+
result = [l_norm, a_norm, b_norm]
62+
63+
else:
64+
raise ValueError("Unsupported 'method' was chosen. Choose either {None, 'modified'}.")
4765

4866
# rebuild LAB
4967
lab = lab_merge(*result)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
from torchstain.tf.normalizers.macenko import TensorFlowMacenkoNormalizer
2-
from torchstain.tf.normalizers.reinhard import TensorFlowReinhardNormalizer
2+
from torchstain.tf.normalizers.reinhard import TensorFlowReinhardNormalizer

torchstain/tf/normalizers/reinhard.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
https://github.com/Peter554/StainTools/blob/master/staintools/reinhard_color_normalizer.py
1212
"""
1313
class TensorFlowReinhardNormalizer(HENormalizer):
14-
def __init__(self):
14+
def __init__(self, method=None):
1515
super().__init__()
16+
self.method = method
1617
self.target_mus = None
1718
self.target_stds = None
1819

@@ -41,9 +42,26 @@ def normalize(self, I):
4142
mus = stack_[:, 0]
4243
stds = stack_[:, 1]
4344

44-
# standardize intensities channel-wise and normalize using target mus and stds
45-
result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \
46-
in zip(labs, mus, stds, self.target_means, self.target_stds)]
45+
# normalize
46+
if self.method is None:
47+
# standardize intensities channel-wise and normalize using target mus and stds
48+
result = [standardize(x, mu_, std_) * std_T + mu_T for x, mu_, std_, mu_T, std_T \
49+
in zip(labs, mus, stds, self.target_means, self.target_stds)]
50+
51+
elif self.method == "modified":
52+
# calculate q
53+
q = (self.target_stds[0] - stds[0]) / self.target_stds[0]
54+
q = 0.05 if q <= 0 else q
55+
56+
# normalize each channel independently
57+
l_norm = mus[0] + (labs[0] - mus[0]) * (1 + q)
58+
a_norm = self.target_means[1] + (labs[1] - mus[1])
59+
b_norm = self.target_means[2] + (labs[2] - mus[2])
60+
61+
result = [l_norm, a_norm, b_norm]
62+
63+
else:
64+
raise ValueError("Unsupported 'method' was chosen. Choose either {None, 'modified'}.")
4765

4866
# rebuild LAB
4967
lab = lab_merge(*result)

0 commit comments

Comments
 (0)