Skip to content

Commit d439a8b

Browse files
committed
Merge remote-tracking branch 'origin' into development
2 parents 33520d5 + c94179d commit d439a8b

File tree

8 files changed

+328
-4
lines changed

8 files changed

+328
-4
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True)
5656
| Macenko | ✓ | ✓ | ✓ |
5757
| Reinhard | ✓ | ✓ | ✓ |
5858
| Modified Reinhard | ✓ | ✓ | ✓ |
59-
| Multi-target Macenko | ✗ | ✓ | ✗ |
59+
| Multi-target Macenko | ✓ | ✓ | ✓ |
6060
| Macenko-Aug | ✓ | ✓ | ✓ |
6161

6262
## Backend comparison

tests/test_tf.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import tensorflow as tf
66
import numpy as np
77

8+
89
def test_cov():
910
x = np.random.randn(10, 10)
1011
cov_np = np.cov(x)
1112
cov_t = torchstain.tf.utils.cov(x)
1213

1314
np.testing.assert_almost_equal(cov_np, cov_t.numpy())
1415

16+
1517
def test_percentile():
1618
x = np.random.randn(10, 10)
1719
p = 20
@@ -20,6 +22,7 @@ def test_percentile():
2022

2123
np.testing.assert_almost_equal(p_np, p_t)
2224

25+
2326
def test_macenko_tf():
2427
size = 1024
2528
curr_file_path = os.path.dirname(os.path.realpath(__file__))
@@ -48,6 +51,7 @@ def test_macenko_tf():
4851
# assess whether the normalized images are identical across backends
4952
np.testing.assert_almost_equal(result_numpy.flatten(), result_tf.flatten(), decimal=2, verbose=True)
5053

54+
5155
def test_reinhard_tf():
5256
size = 1024
5357
curr_file_path = os.path.dirname(os.path.realpath(__file__))
@@ -75,3 +79,37 @@ def test_reinhard_tf():
7579

7680
# assess whether the normalized images are identical across backends
7781
np.testing.assert_almost_equal(result_numpy.flatten(), result_tf.flatten(), decimal=2, verbose=True)
82+
83+
84+
def test_multistain_tf():
85+
size = 1024
86+
curr_file_path = os.path.dirname(os.path.realpath(__file__))
87+
target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size))
88+
to_transform = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/source.png")), cv2.COLOR_BGR2RGB), (size, size))
89+
90+
# setup preprocessing and preprocess image to be normalized
91+
T = lambda x: tf.convert_to_tensor(np.moveaxis(x, -1, 0).astype("float32")) # * 255
92+
t_to_transform = T(to_transform)
93+
target_transformed = T(target)
94+
95+
# move channel to first
96+
target_numpy = np.moveaxis(target, -1, 0)
97+
to_transform_numpy = np.moveaxis(to_transform, -1, 0)
98+
99+
# initialize normalizers for each backend and fit to target image
100+
normalizer = torchstain.normalizers.MultiMacenkoNormalizer(backend='numpy')
101+
normalizer.fit([target_numpy, target_numpy, target_numpy])
102+
103+
tf_normalizer = torchstain.normalizers.MultiMacenkoNormalizer(backend='tensorflow')
104+
tf_normalizer.fit([target_transformed, target_transformed, target_transformed])
105+
106+
# transform
107+
result_numpy, _, _ = normalizer.normalize(I=to_transform_numpy, stains=True)
108+
result_tf, _, _ = tf_normalizer.normalize(I=t_to_transform, stains=True)
109+
110+
# convert to numpy and set dtype
111+
result_numpy = result_numpy.astype("float32") / 255.
112+
result_tf = result_tf.numpy().astype("float32") / 255.
113+
114+
# assess whether the normalized images are identical across backends
115+
np.testing.assert_almost_equal(result_numpy.flatten(), result_tf.flatten(), decimal=2, verbose=True)

tests/test_torch.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
def setup_function(fn):
1212
print("torch version:", torch.__version__, "torchvision version:", torchvision.__version__)
1313

14+
1415
def test_cov():
1516
x = np.random.randn(10, 10)
1617
cov_np = np.cov(x)
1718
cov_t = torchstain.torch.utils.cov(torch.tensor(x))
1819

1920
np.testing.assert_almost_equal(cov_np, cov_t.numpy())
2021

22+
2123
def test_percentile():
2224
x = np.random.randn(10, 10)
2325
p = 20
@@ -26,6 +28,7 @@ def test_percentile():
2628

2729
np.testing.assert_almost_equal(p_np, p_t)
2830

31+
2932
def test_macenko_torch():
3033
size = 1024
3134
curr_file_path = os.path.dirname(os.path.realpath(__file__))
@@ -57,6 +60,7 @@ def test_macenko_torch():
5760
# assess whether the normalized images are identical across backends
5861
np.testing.assert_almost_equal(result_numpy.flatten(), result_torch.flatten(), decimal=2, verbose=True)
5962

63+
6064
def test_multitarget_macenko_torch():
6165
size = 1024
6266
curr_file_path = os.path.dirname(os.path.realpath(__file__))
@@ -122,3 +126,40 @@ def test_reinhard_torch():
122126

123127
# assess whether the normalized images are identical across backends
124128
np.testing.assert_almost_equal(result_numpy.flatten(), result_torch.flatten(), decimal=2, verbose=True)
129+
130+
131+
def test_macenko_torch():
132+
size = 1024
133+
curr_file_path = os.path.dirname(os.path.realpath(__file__))
134+
target = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/target.png")), cv2.COLOR_BGR2RGB), (size, size))
135+
to_transform = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/source.png")), cv2.COLOR_BGR2RGB), (size, size))
136+
137+
# setup preprocessing and preprocess image to be normalized
138+
T = transforms.Compose([
139+
transforms.ToTensor(),
140+
transforms.Lambda(lambda x: x * 255)
141+
])
142+
t_to_transform = T(to_transform)
143+
target_transformed = T(target)
144+
145+
# move channel to first
146+
target_numpy = np.moveaxis(target, -1, 0)
147+
to_transform_numpy = np.moveaxis(to_transform, -1, 0)
148+
149+
# initialize normalizers for each backend and fit to target image
150+
normalizer = torchstain.normalizers.MultiMacenkoNormalizer(backend='numpy')
151+
normalizer.fit([target_numpy, target_numpy, target_numpy])
152+
153+
torch_normalizer = torchstain.normalizers.MultiMacenkoNormalizer(backend='torch')
154+
torch_normalizer.fit([target_transformed, target_transformed, target_transformed])
155+
156+
# transform
157+
result_numpy, _, _ = normalizer.normalize(I=to_transform_numpy, stains=True)
158+
result_torch, _, _ = torch_normalizer.normalize(I=t_to_transform, stains=True)
159+
160+
# convert to numpy and set dtype
161+
result_numpy = result_numpy.astype("float32") / 255.
162+
result_torch = result_torch.numpy().astype("float32") / 255.
163+
164+
# assess whether the normalized images are identical across backends
165+
np.testing.assert_almost_equal(result_numpy.flatten(), result_torch.flatten(), decimal=2, verbose=True)
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
def MultiMacenkoNormalizer(backend="torch", **kwargs):
22
if backend == "numpy":
3-
raise NotImplementedError("MultiMacenkoNormalizer is not implemented for NumPy backend")
3+
from torchstain.numpy.normalizers import NumpyMultiMacenkoNormalizer
4+
return NumpyMultiMacenkoNormalizer(**kwargs)
45
elif backend == "torch":
56
from torchstain.torch.normalizers import TorchMultiMacenkoNormalizer
67
return TorchMultiMacenkoNormalizer(**kwargs)
78
elif backend == "tensorflow":
8-
raise NotImplementedError("MultiMacenkoNormalizer is not implemented for TensorFlow backend")
9+
from torchstain.tf.normalizers import TensorFlowMultiMacenkoNormalizer
10+
return TensorFlowMultiMacenkoNormalizer(**kwargs)
911
else:
1012
raise Exception(f"Unsupported backend {backend}")
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from .macenko import NumpyMacenkoNormalizer
2-
from .reinhard import NumpyReinhardNormalizer
2+
from .reinhard import NumpyReinhardNormalizer
3+
from .multitarget import NumpyMultiMacenkoNormalizer
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import numpy as np
2+
3+
class NumpyMultiMacenkoNormalizer:
4+
def __init__(self, norm_mode="avg-post"):
5+
self.norm_mode = norm_mode
6+
self.HERef = np.array([[0.5626, 0.2159],
7+
[0.7201, 0.8012],
8+
[0.4062, 0.5581]])
9+
self.maxCRef = np.array([1.9705, 1.0308])
10+
11+
def __convert_rgb2od(self, I, Io, beta):
12+
I = np.transpose(I, (1, 2, 0))
13+
OD = -np.log((I.reshape(-1, I.shape[-1]).astype(float) + 1) / Io)
14+
ODhat = OD[~np.any(OD < beta, axis=1)]
15+
return OD, ODhat
16+
17+
def __find_phi_bounds(self, ODhat, eigvecs, alpha):
18+
That = np.dot(ODhat, eigvecs)
19+
phi = np.arctan2(That[:, 1], That[:, 0])
20+
21+
minPhi = np.percentile(phi, alpha)
22+
maxPhi = np.percentile(phi, 100 - alpha)
23+
24+
return minPhi, maxPhi
25+
26+
def __find_HE_from_bounds(self, eigvecs, minPhi, maxPhi):
27+
vMin = np.dot(eigvecs, [np.cos(minPhi), np.sin(minPhi)]).reshape(-1, 1)
28+
vMax = np.dot(eigvecs, [np.cos(maxPhi), np.sin(maxPhi)]).reshape(-1, 1)
29+
30+
HE = np.concatenate([vMin, vMax], axis=1) if vMin[0] > vMax[0] else np.concatenate([vMax, vMin], axis=1)
31+
return HE
32+
33+
def __find_HE(self, ODhat, eigvecs, alpha):
34+
minPhi, maxPhi = self.__find_phi_bounds(ODhat, eigvecs, alpha)
35+
return self.__find_HE_from_bounds(eigvecs, minPhi, maxPhi)
36+
37+
def __find_concentration(self, OD, HE):
38+
Y = OD.T
39+
C, _, _, _ = np.linalg.lstsq(HE, Y, rcond=None)
40+
return C
41+
42+
def __compute_matrices_single(self, I, Io, alpha, beta):
43+
OD, ODhat = self.__convert_rgb2od(I, Io, beta)
44+
45+
cov_matrix = np.cov(ODhat.T)
46+
eigvals, eigvecs = np.linalg.eigh(cov_matrix)
47+
eigvecs = eigvecs[:, [1, 2]]
48+
49+
HE = self.__find_HE(ODhat, eigvecs, alpha)
50+
C = self.__find_concentration(OD, HE)
51+
maxC = np.array([np.percentile(C[0, :], 99), np.percentile(C[1, :], 99)])
52+
53+
return HE, C, maxC
54+
55+
def fit(self, Is, Io=240, alpha=1, beta=0.15):
56+
if self.norm_mode == "avg-post":
57+
HEs, _, maxCs = zip(*[self.__compute_matrices_single(I, Io, alpha, beta) for I in Is])
58+
59+
self.HERef = np.mean(HEs, axis=0)
60+
self.maxCRef = np.mean(maxCs, axis=0)
61+
elif self.norm_mode == "concat":
62+
ODs, ODhats = zip(*[self.__convert_rgb2od(I, Io, beta) for I in Is])
63+
OD = np.vstack(ODs)
64+
ODhat = np.vstack(ODhats)
65+
66+
cov_matrix = np.cov(ODhat.T)
67+
eigvals, eigvecs = np.linalg.eigh(cov_matrix)
68+
eigvecs = eigvecs[:, [1, 2]]
69+
70+
HE = self.__find_HE(ODhat, eigvecs, alpha)
71+
C = self.__find_concentration(OD, HE)
72+
maxCs = np.array([np.percentile(C[0, :], 99), np.percentile(C[1, :], 99)])
73+
74+
self.HERef = HE
75+
self.maxCRef = maxCs
76+
elif self.norm_mode == "avg-pre":
77+
ODs, ODhats = zip(*[self.__convert_rgb2od(I, Io, beta) for I in Is])
78+
79+
covs = [np.cov(ODhat.T) for ODhat in ODhats]
80+
eigvecs = np.mean([np.linalg.eigh(cov)[1][:, [1, 2]] for cov in covs], axis=0)
81+
82+
OD = np.vstack(ODs)
83+
ODhat = np.vstack(ODhats)
84+
85+
HE = self.__find_HE(ODhat, eigvecs, alpha)
86+
C = self.__find_concentration(OD, HE)
87+
maxCs = np.array([np.percentile(C[0, :], 99), np.percentile(C[1, :], 99)])
88+
89+
self.HERef = HE
90+
self.maxCRef = maxCs
91+
elif self.norm_mode in ["fixed-single", "stochastic-single"]:
92+
self.HERef, _, self.maxCRef = self.__compute_matrices_single(Is[0], Io, alpha, beta)
93+
else:
94+
raise ValueError("Unknown norm mode")
95+
96+
def normalize(self, I, Io=240, alpha=1, beta=0.15, stains=True):
97+
c, h, w = I.shape
98+
99+
HE, C, maxC = self.__compute_matrices_single(I, Io, alpha, beta)
100+
C = (self.maxCRef / maxC).reshape(-1, 1) * C
101+
102+
Inorm = Io * np.exp(-np.dot(self.HERef, C))
103+
Inorm[Inorm > 255] = 255
104+
Inorm = np.transpose(Inorm, (1, 0)).reshape(h, w, c).astype(np.int32)
105+
106+
H, E = None, None
107+
108+
if stains:
109+
H = Io * np.exp(-np.dot(self.HERef[:, 0].reshape(-1, 1), C[0, :].reshape(1, -1)))
110+
H[H > 255] = 255
111+
H = np.transpose(H, (1, 0)).reshape(h, w, c).astype(np.int32)
112+
113+
E = Io * np.exp(-np.dot(self.HERef[:, 1].reshape(-1, 1), C[1, :].reshape(1, -1)))
114+
E[E > 255] = 255
115+
E = np.transpose(E, (1, 0)).reshape(h, w, c).astype(np.int32)
116+
117+
return Inorm, H, E
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from torchstain.tf.normalizers.macenko import TensorFlowMacenkoNormalizer
22
from torchstain.tf.normalizers.reinhard import TensorFlowReinhardNormalizer
3+
from torchstain.tf.normalizers.multitarget import TensorFlowMultiMacenkoNormalizer

0 commit comments

Comments
 (0)