From ece6e7c4d0ff182fc62295962a146d7e92eee2a8 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 14:19:02 +0200 Subject: [PATCH 01/63] update backend comparison --- README.md | 20 ++++++++++---------- compare.py | 34 +++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6d45a3e..860d4fc 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,16 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) Results with 10 runs per size on a Intel(R) Core(TM) i5-8365U CPU @ 1.60GHz -| size | numpy avg. time | numpy tot. time | torch avg. time | torch tot. time | -|--------|-------------------|-------------------|-------------------|-------------------| -| 224x224 | 0.0323s ± 0.0032 | 0.3231s | 0.0234s ± 0.0384 | 0.2340s | -| 448x448 | 0.1228s ± 0.0042 | 1.2280s | 0.0395s ± 0.0168 | 0.3954s | -| 672x672 | 0.2653s ± 0.0106 | 2.6534s | 0.0753s ± 0.0157 | 0.7527s | -| 896x896 | 0.4940s ± 0.0208 | 4.9397s | 0.1262s ± 0.0159 | 1.2622s | -| 1120x1120 | 0.6888s ± 0.0081 | 6.8883s | 0.2002s ± 0.0141 | 2.0021s | -| 1344x1344 | 1.0145s ± 0.0089 | 10.1448s | 0.2703s ± 0.0136 | 2.7026s | -| 1568x1568 | 1.2620s ± 0.0133 | 12.6200s | 0.3680s ± 0.0128 | 3.6795s | -| 1792x1792 | 1.4289s ± 0.0128 | 14.2886s | 0.5968s ± 0.0160 | 5.9676s | +| size | numpy avg. time | torch avg. time | tf avg. time | +|--------|-------------------|-------------------|------------------| +| 224 | 0.0182s ± 0.0016 | 0.0180s ± 0.0390 | 0.0048s ± 0.0002 | +| 448 | 0.0880s ± 0.0224 | 0.0283s ± 0.0172 | 0.0210s ± 0.0025 | +| 672 | 0.1810s ± 0.0139 | 0.0463s ± 0.0301 | 0.0354s ± 0.0018 | +| 896 | 0.3013s ± 0.0377 | 0.0820s ± 0.0329 | 0.0713s ± 0.0008 | +| 1120 | 0.4694s ± 0.0350 | 0.1321s ± 0.0237 | 0.1036s ± 0.0042 | +| 1344 | 0.6640s ± 0.0553 | 0.1665s ± 0.0026 | 0.1663s ± 0.0021 | +| 1568 | 1.1935s ± 0.0739 | 0.2590s ± 0.0088 | 0.2531s ± 0.0031 | +| 1792 | 1.4523s ± 0.0207 | 0.3402s ± 0.0114 | 0.3080s ± 0.0188 | ## Reference diff --git a/compare.py b/compare.py index 0fbf4f8..cacab8c 100644 --- a/compare.py +++ b/compare.py @@ -14,24 +14,26 @@ def measure(size, N): target = cv2.resize(cv2.cvtColor(cv2.imread("./data/target.png"), cv2.COLOR_BGR2RGB), (size, size)) to_transform = cv2.resize(cv2.cvtColor(cv2.imread("./data/source.png"), cv2.COLOR_BGR2RGB), (size, size)) - normalizer = torchstain.MacenkoNormalizer(backend='numpy') + normalizer = torchstain.normalizers.MacenkoNormalizer(backend='numpy') normalizer.fit(target) - T = transforms.Compose([ transforms.ToPILImage(), transforms.ToTensor(), transforms.Lambda(lambda x: x*255) ]) - torch_normalizer = torchstain.MacenkoNormalizer(backend='torch') + 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) t_np = [] start_np = time.perf_counter() - for i in range(N): + for _ in range(N): tic = time.perf_counter() _ = normalizer.normalize(to_transform) toc = time.perf_counter() @@ -42,7 +44,7 @@ def measure(size, N): t_torch = [] start_torch = time.perf_counter() - for i in range(N): + for _ in range(N): tic = time.perf_counter() _ = torch_normalizer.normalize(t_to_transform) toc = time.perf_counter() @@ -50,18 +52,32 @@ def measure(size, N): end_torch = time.perf_counter() t_torch = np.array(t_torch) + + t_tf = [] + start_tf = time.perf_counter() + for _ in range(N): + tic = time.perf_counter() + _ = torch_normalizer.normalize(t_to_transform) + toc = time.perf_counter() + t_tf.append(toc-tic) + end_tf = time.perf_counter() + t_tf = np.array(t_tf) + """ print(f'Results of {N} runs:') print(f'numpy: {t_np.mean():.4f}s ± {t_np.std():.4f} (tot: {end_np-start_np:.4f}s)') print(f'torch: {t_torch.mean():.4f}s ± {t_torch.std():.4f} (tot: {end_torch-start_torch:.4f}s)') """ - return t_np, end_np-start_np, t_torch, end_torch-start_torch + return t_np, end_np-start_np, t_torch, end_torch-start_torch, t_tf, end_tf-start_tf table = [] for size in [224, 448, 672, 896, 1120, 1344, 1568, 1792]: - t_np, tot_np, t_torch, tot_torch = measure(size, N=10) - row = [size, f'{t_np.mean():.4f}s ± {t_np.std():.4f}', f'{tot_np:.4f}s', f'{t_torch.mean():.4f}s ± {t_torch.std():.4f}', f'{tot_torch:.4f}s'] + t_np, tot_np, t_torch, tot_torch, t_tf, tot_tf = measure(size, N=10) + # row = [size, f'{t_np.mean():.4f}s ± {t_np.std():.4f}', f'{tot_np:.4f}s', f'{t_torch.mean():.4f}s ± {t_torch.std():.4f}', f'{tot_torch:.4f}s'] + row = [size, f'{t_np.mean():.4f}s ± {t_np.std():.4f}', f'{t_torch.mean():.4f}s ± {t_torch.std():.4f}', f'{t_tf.mean():.4f}s ± {t_tf.std():.4f}'] table.append(row) -print(tabulate(table, headers=['size', 'numpy avg. time', 'numpy tot. time', 'torch avg. time', 'torch tot. time'], tablefmt='github')) +# print(tabulate(table, headers=['size', 'numpy avg. time', 'numpy tot. time', 'torch avg. time', 'torch tot. time'], tablefmt='github')) +print(tabulate(table, headers=['size', 'numpy avg. time', 'torch avg. time', 'tf avg. time'], tablefmt='github')) + From 5e2fdf525caa114c322aed492afdcb453df93882 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 14:19:30 +0200 Subject: [PATCH 02/63] bump version to 1.2.0 --- setup.py | 2 +- torchstain/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 7eb0b9e..46c0d67 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='torchstain', - version='1.1.0', + version='1.2.0', description='Pytorch stain normalization utils', long_description=README, long_description_content_type='text/markdown', diff --git a/torchstain/__init__.py b/torchstain/__init__.py index 980e3a6..dab8618 100644 --- a/torchstain/__init__.py +++ b/torchstain/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.1.0' +__version__ = '1.2.0' from torchstain.base import normalizers \ No newline at end of file From a8b57f2119290a58032a1ce68774d54f7171655e Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 14:25:36 +0200 Subject: [PATCH 03/63] update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 860d4fc..1dcb32e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Normalization algorithms currently implemented: pip install torchstain ``` +To install a specific backend use either ```torchstain[torch]``` or ```torchstain[tf]```. The numpy backend is included by default in both. + ## Example Usage ```python From 838a806aa9f80b2b98f0919fa2be669bc657c1c7 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 14:29:53 +0200 Subject: [PATCH 04/63] update README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 1dcb32e..b1c7e2e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) ![alt text](result.png) +## Implemented algorithms + +| Algorithm | numpy | torch | tensorflow | +|-|-|-|-| +| Macenko | ✓ | ✓ | ✓ | + ## Backend comparison From bbdf449ae38bbd50394c4b7a3f1126f81d3cd178 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 15:23:15 +0200 Subject: [PATCH 05/63] rename cov_tf and percentile_tf to cov and percentile --- torchstain/tf/normalizers/macenko.py | 10 +++++----- torchstain/tf/utils/__init__.py | 4 ++-- torchstain/tf/utils/cov.py | 2 +- torchstain/tf/utils/percentile.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/torchstain/tf/normalizers/macenko.py b/torchstain/tf/normalizers/macenko.py index e9a69ce..bf21666 100644 --- a/torchstain/tf/normalizers/macenko.py +++ b/torchstain/tf/normalizers/macenko.py @@ -1,6 +1,6 @@ import tensorflow as tf from torchstain.base.normalizers.he_normalizer import HENormalizer -from torchstain.tf.utils import cov_tf, percentile_tf, solveLS +from torchstain.tf.utils import cov, percentile, solveLS import numpy as np import tensorflow.keras.backend as K @@ -35,8 +35,8 @@ def __find_HE(self, ODhat, eigvecs, alpha): That = tf.linalg.matmul(ODhat, eigvecs) phi = tf.math.atan2(That[:, 1], That[:, 0]) - minPhi = percentile_tf(phi, alpha) - maxPhi = percentile_tf(phi, 100 - alpha) + minPhi = percentile(phi, alpha) + maxPhi = percentile(phi, 100 - alpha) vMin = tf.matmul(eigvecs, tf.expand_dims(tf.stack((tf.math.cos(minPhi), tf.math.sin(minPhi))), axis=-1)) vMax = tf.matmul(eigvecs, tf.expand_dims(tf.stack((tf.math.cos(maxPhi), tf.math.sin(maxPhi))), axis=-1)) @@ -58,13 +58,13 @@ def __compute_matrices(self, I, Io, alpha, beta): OD, ODhat = self.__convert_rgb2od(I, Io=Io, beta=beta) # compute eigenvectors - _, eigvecs = tf.linalg.eigh(cov_tf(tf.transpose(ODhat))) + _, eigvecs = tf.linalg.eigh(cov(tf.transpose(ODhat))) eigvecs = eigvecs[:, 1:3] HE = self.__find_HE(ODhat, eigvecs, alpha) C = self.__find_concentration(OD, HE) - maxC = tf.stack([percentile_tf(C[0, :], 99), percentile_tf(C[1, :], 99)]) + maxC = tf.stack([percentile(C[0, :], 99), percentile(C[1, :], 99)]) return HE, C, maxC diff --git a/torchstain/tf/utils/__init__.py b/torchstain/tf/utils/__init__.py index 1c65f31..4ac551d 100644 --- a/torchstain/tf/utils/__init__.py +++ b/torchstain/tf/utils/__init__.py @@ -1,3 +1,3 @@ -from torchstain.tf.utils.cov import cov_tf -from torchstain.tf.utils.percentile import percentile_tf +from torchstain.tf.utils.cov import cov +from torchstain.tf.utils.percentile import percentile from torchstain.tf.normalizers.solveLS import solveLS diff --git a/torchstain/tf/utils/cov.py b/torchstain/tf/utils/cov.py index c0ce049..3429d90 100644 --- a/torchstain/tf/utils/cov.py +++ b/torchstain/tf/utils/cov.py @@ -1,6 +1,6 @@ import tensorflow as tf -def cov_tf(x): +def cov(x): """ https://en.wikipedia.org/wiki/Covariance_matrix """ diff --git a/torchstain/tf/utils/percentile.py b/torchstain/tf/utils/percentile.py index c6697dc..2db4aeb 100644 --- a/torchstain/tf/utils/percentile.py +++ b/torchstain/tf/utils/percentile.py @@ -1,7 +1,7 @@ from typing import Union import tensorflow as tf -def percentile_tf(t: tf.Tensor, q: float) -> Union[int, float]: +def percentile(t: tf.Tensor, q: float) -> Union[int, float]: """ Return the ``q``-th percentile of the flattened input tensor's data. From a34fc47fb1a5cf1ff58cc31976d6bf7de6e58bba Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 15:30:09 +0200 Subject: [PATCH 06/63] update tests --- .github/workflows/tests.yml | 40 ++--------------- tests/test_normalizers.py | 45 ------------------- tests/{test_tf_normalizer.py => test_tf.py} | 17 +++++++ ...test_torch_normalizer.py => test_torch.py} | 21 ++++++++- tests/test_utils.py | 19 -------- 5 files changed, 40 insertions(+), 102 deletions(-) delete mode 100644 tests/test_normalizers.py rename tests/{test_tf_normalizer.py => test_tf.py} (78%) rename tests/{test_torch_normalizer.py => test_torch.py} (77%) delete mode 100644 tests/test_utils.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3849cee..44f455e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,46 +31,13 @@ jobs: path: ${{github.workspace}}/dist/torchstain-*.whl if-no-files-found: error - test: - needs: build - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [windows-2019, ubuntu-18.04, macos-11] - python-version: [3.6, 3.7, 3.8, 3.9] - - steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Download artifact - uses: actions/download-artifact@master - with: - name: "Python wheel" - - - name: Install deps and wheel - run: | - pip install tensorflow torch - pip install --find-links=${{github.workspace}} torchstain - - - name: Install test dependencies - run: pip install opencv-python torchvision scikit-image pytest - - - name: Run tests - run: | - pytest -v tests/test_utils.py - pytest -v tests/test_normalizers.py - test-tf: needs: build runs-on: ${{ matrix.os }} strategy: matrix: os: [ windows-2019, ubuntu-18.04, macos-11 ] - python-version: [ 3.7 ] + python-version: [ 3.7, 3.8, 3.9 ] steps: - uses: actions/checkout@v1 @@ -101,7 +68,8 @@ jobs: strategy: matrix: os: [ windows-2019, ubuntu-18.04, macos-11 ] - python-version: [ 3.7 ] + python-version: [ 3.7, 3.8, 3.9 ] + pytorch-version: [1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0] steps: - uses: actions/checkout@v1 @@ -117,7 +85,7 @@ jobs: - name: Install deps and wheel wheel run: | - pip install torch + pip install torch==${{ matrix.pytorch-version }} pip install --find-links=${{github.workspace}} torchstain - name: Install test dependencies diff --git a/tests/test_normalizers.py b/tests/test_normalizers.py deleted file mode 100644 index 5769b96..0000000 --- a/tests/test_normalizers.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import cv2 -import torchstain -import torch -from torchvision import transforms -import time -from skimage.metrics import structural_similarity as ssim -import numpy as np - -def test_normalize_all(): - 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.MacenkoNormalizer(backend='numpy') - normalizer.fit(target) - - torch_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='torch') - torch_normalizer.fit(T(target)) - - tf_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='tensorflow') - tf_normalizer.fit(T(target)) - - # transform - result_numpy, _, _ = normalizer.normalize(I=to_transform, stains=True) - result_torch, _, _ = torch_normalizer.normalize(I=t_to_transform, stains=True) - result_tf, _, _ = tf_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_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_torch.flatten()), 1.0, decimal=4, verbose=True) - np.testing.assert_almost_equal(ssim(result_numpy.flatten(), result_tf.flatten()), 1.0, decimal=4, verbose=True) diff --git a/tests/test_tf_normalizer.py b/tests/test_tf.py similarity index 78% rename from tests/test_tf_normalizer.py rename to tests/test_tf.py index c67d8e9..075093d 100644 --- a/tests/test_tf_normalizer.py +++ b/tests/test_tf.py @@ -1,11 +1,28 @@ import os import cv2 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(): + x = np.random.randn(10, 10) + cov_np = np.cov(x) + cov_t = torchstain.tf.utils.cov(x) + + np.testing.assert_almost_equal(cov_np, cov_t.numpy()) + +def test_percentile(): + x = np.random.randn(10, 10) + p = 20 + p_np = np.percentile(x, p, interpolation='nearest') + p_t = torchstain.tf.utils.percentile(x, p) + + np.testing.assert_almost_equal(p_np, p_t) + + def test_normalize_tf(): size = 1024 curr_file_path = os.path.dirname(os.path.realpath(__file__)) diff --git a/tests/test_torch_normalizer.py b/tests/test_torch.py similarity index 77% rename from tests/test_torch_normalizer.py rename to tests/test_torch.py index aa493fc..0d79ed1 100644 --- a/tests/test_torch_normalizer.py +++ b/tests/test_torch.py @@ -1,11 +1,28 @@ import os import cv2 import torchstain +import torchstain.torch import torch -from torchvision import transforms import time -from skimage.metrics import structural_similarity as ssim import numpy as np +from torchvision import transforms +from skimage.metrics import structural_similarity as ssim + + +def test_cov(): + x = np.random.randn(10, 10) + cov_np = np.cov(x) + cov_t = torchstain.torch.utils.cov(torch.tensor(x)) + + np.testing.assert_almost_equal(cov_np, cov_t.numpy()) + +def test_percentile(): + x = np.random.randn(10, 10) + p = 20 + p_np = np.percentile(x, p, interpolation='nearest') + p_t = torchstain.torch.utils.percentile(torch.tensor(x), p) + + np.testing.assert_almost_equal(p_np, p_t) def test_normalize_torch(): size = 1024 diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 266215e..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,19 +0,0 @@ -import torch -import torchstain -import torchstain.torch -import numpy as np - -def test_cov(): - x = np.random.randn(10, 10) - cov_np = np.cov(x) - cov_t = torchstain.torch.utils.cov(torch.tensor(x)) - - np.testing.assert_almost_equal(cov_np, cov_t.numpy()) - -def test_percentile(): - x = np.random.randn(10, 10) - p = 20 - p_np = np.percentile(x, p, interpolation='nearest') - p_t = torchstain.torch.utils.percentile(torch.tensor(x), p) - - np.testing.assert_almost_equal(p_np, p_t) From c2c39b40c26c7c66645d617298d0cbb6a546b0ac Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 15:31:30 +0200 Subject: [PATCH 07/63] Use CPU flavor of torch and fix tf and protobuf version --- .github/workflows/tests.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 44f455e..1e22526 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,8 +36,10 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ windows-2019, ubuntu-18.04, macos-11 ] + # os: [ windows-2019, ubuntu-18.04, macos-11 ] + os: [ubuntu-18.04, ubuntu-20.04] python-version: [ 3.7, 3.8, 3.9 ] + tf-version: [2.7.0, 2.8.0, 2.9.0] steps: - uses: actions/checkout@v1 @@ -53,21 +55,22 @@ jobs: - name: Install deps and wheel run: | - pip install tensorflow + pip install tensorflow==${{ matrix.tf-version }} protobuf==3.20.* pip install --find-links=${{github.workspace}} torchstain - name: Install test dependencies run: pip install opencv-python scikit-image pytest - name: Run tests - run: pytest -v tests/test_tf_normalizer.py + run: pytest -v tests/test_tf.py test-torch: needs: build runs-on: ${{ matrix.os }} strategy: matrix: - os: [ windows-2019, ubuntu-18.04, macos-11 ] + # os: [ windows-2019, ubuntu-18.04, macos-11 ] + os: [ubuntu-18.04, ubuntu-20.04] python-version: [ 3.7, 3.8, 3.9 ] pytorch-version: [1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0] @@ -85,11 +88,11 @@ jobs: - name: Install deps and wheel wheel run: | - pip install torch==${{ matrix.pytorch-version }} + pip install --index-url https://download.pytorch.org/whl/cpu/torch/ --extra-index-url https://pypi.org/simple/ torch==${{ matrix.pytorch-version }} pip install --find-links=${{github.workspace}} torchstain - name: Install test dependencies run: pip install opencv-python torchvision scikit-image pytest - name: Run tests - run: pytest -v tests/test_torch_normalizer.py + run: pytest -v tests/test_torch.py From ecacfb8dd42e44c49d844de89b9be699249de5ed Mon Sep 17 00:00:00 2001 From: andreped Date: Mon, 8 Aug 2022 19:22:03 +0200 Subject: [PATCH 08/63] fixed tf percentile test - off by one --- tests/test_tf.py | 3 +-- tests/test_torch.py | 1 - torchstain/tf/utils/percentile.py | 4 ++-- torchstain/torch/utils/percentile.py | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_tf.py b/tests/test_tf.py index 075093d..b38ef74 100644 --- a/tests/test_tf.py +++ b/tests/test_tf.py @@ -18,11 +18,10 @@ def test_percentile(): x = np.random.randn(10, 10) p = 20 p_np = np.percentile(x, p, interpolation='nearest') - p_t = torchstain.tf.utils.percentile(x, p) + p_t = torchstain.tf.utils.percentile(tf.convert_to_tensor(x), p) np.testing.assert_almost_equal(p_np, p_t) - def test_normalize_tf(): size = 1024 curr_file_path = os.path.dirname(os.path.realpath(__file__)) diff --git a/tests/test_torch.py b/tests/test_torch.py index 0d79ed1..168d2f1 100644 --- a/tests/test_torch.py +++ b/tests/test_torch.py @@ -8,7 +8,6 @@ from torchvision import transforms from skimage.metrics import structural_similarity as ssim - def test_cov(): x = np.random.randn(10, 10) cov_np = np.cov(x) diff --git a/torchstain/tf/utils/percentile.py b/torchstain/tf/utils/percentile.py index 2db4aeb..de8c6ac 100644 --- a/torchstain/tf/utils/percentile.py +++ b/torchstain/tf/utils/percentile.py @@ -14,5 +14,5 @@ def percentile(t: tf.Tensor, q: float) -> Union[int, float]: :param q: Percentile to compute, which must be between 0 and 100 inclusive. :return: Resulting value (scalar). """ - k = 1 + tf.math.round(.01 * tf.cast(q, tf.float32) * (tf.cast(tf.size(t), tf.float32) - 1)) - return tf.sort(tf.reshape(t, [-1]))[tf.cast(k, tf.int32)] + k = 1 + tf.math.round(.01 * tf.cast(q, tf.float32) * (tf.cast(tf.math.reduce_prod(tf.size(t)), tf.float32) - 1)) + return tf.sort(tf.reshape(t, [-1]))[tf.cast(k - 1, tf.int32)] diff --git a/torchstain/torch/utils/percentile.py b/torchstain/torch/utils/percentile.py index 188f1bb..08273d4 100644 --- a/torchstain/torch/utils/percentile.py +++ b/torchstain/torch/utils/percentile.py @@ -6,7 +6,7 @@ """ def percentile(t: torch.tensor, q: float) -> Union[int, float]: """ - Return the ``q``-th percentile of the flattened input tensor's data. + Return the ``q``-th percentile of the flattenepip d input tensor's data. CAUTION: * Needs PyTorch >= 1.1.0, as ``torch.kthvalue()`` is used. From 89a84075f818a51570329403a54474ec5f47f724 Mon Sep 17 00:00:00 2001 From: andreped Date: Mon, 8 Aug 2022 19:24:31 +0200 Subject: [PATCH 09/63] removed redundant tensor conversion --- tests/test_tf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tf.py b/tests/test_tf.py index b38ef74..b24757a 100644 --- a/tests/test_tf.py +++ b/tests/test_tf.py @@ -18,7 +18,7 @@ def test_percentile(): x = np.random.randn(10, 10) p = 20 p_np = np.percentile(x, p, interpolation='nearest') - p_t = torchstain.tf.utils.percentile(tf.convert_to_tensor(x), p) + p_t = torchstain.tf.utils.percentile(x, p) np.testing.assert_almost_equal(p_np, p_t) From 5d1b729f1e8cc98a700ce2ab1679d958edfe1b3e Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Mon, 8 Aug 2022 21:23:02 +0200 Subject: [PATCH 10/63] move solveLS to torchstain.tf.utils --- torchstain/tf/utils/__init__.py | 2 +- torchstain/tf/{normalizers => utils}/solveLS.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename torchstain/tf/{normalizers => utils}/solveLS.py (100%) diff --git a/torchstain/tf/utils/__init__.py b/torchstain/tf/utils/__init__.py index 4ac551d..253a3d0 100644 --- a/torchstain/tf/utils/__init__.py +++ b/torchstain/tf/utils/__init__.py @@ -1,3 +1,3 @@ from torchstain.tf.utils.cov import cov from torchstain.tf.utils.percentile import percentile -from torchstain.tf.normalizers.solveLS import solveLS +from torchstain.tf.utils.solveLS import solveLS diff --git a/torchstain/tf/normalizers/solveLS.py b/torchstain/tf/utils/solveLS.py similarity index 100% rename from torchstain/tf/normalizers/solveLS.py rename to torchstain/tf/utils/solveLS.py From 32f6f67524d7ea983e17ace3e19d904ee2ddbedd Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 10:31:19 +0200 Subject: [PATCH 11/63] fix type hint --- .github/workflows/tests.yml | 2 +- torchstain/torch/utils/percentile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e22526..df40da1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -92,7 +92,7 @@ jobs: pip install --find-links=${{github.workspace}} torchstain - name: Install test dependencies - run: pip install opencv-python torchvision scikit-image pytest + run: pip install --no-dependencies opencv-python torchvision scikit-image pytest - name: Run tests run: pytest -v tests/test_torch.py diff --git a/torchstain/torch/utils/percentile.py b/torchstain/torch/utils/percentile.py index 08273d4..08c28ef 100644 --- a/torchstain/torch/utils/percentile.py +++ b/torchstain/torch/utils/percentile.py @@ -4,7 +4,7 @@ """ Author: https://gist.github.com/spezold/42a451682422beb42bc43ad0c0967a30 """ -def percentile(t: torch.tensor, q: float) -> Union[int, float]: +def percentile(t: torch.Tensor, q: float) -> Union[int, float]: """ Return the ``q``-th percentile of the flattenepip d input tensor's data. From 163d10b249ad57c09d573256a7ed82815fd958b4 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 10:37:44 +0200 Subject: [PATCH 12/63] print version --- tests/test_torch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_torch.py b/tests/test_torch.py index 168d2f1..f4363e7 100644 --- a/tests/test_torch.py +++ b/tests/test_torch.py @@ -3,11 +3,16 @@ import torchstain 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__) + def test_cov(): x = np.random.randn(10, 10) cov_np = np.cov(x) From 45b98dabe62d3962ec928e5d8899adf33705f92e Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 10:38:09 +0200 Subject: [PATCH 13/63] disable stdout capture --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index df40da1..e703af9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,7 +62,7 @@ jobs: run: pip install opencv-python scikit-image pytest - name: Run tests - run: pytest -v tests/test_tf.py + run: pytest -vs tests/test_tf.py test-torch: needs: build @@ -92,7 +92,7 @@ jobs: pip install --find-links=${{github.workspace}} torchstain - name: Install test dependencies - run: pip install --no-dependencies opencv-python torchvision scikit-image pytest + run: pip install opencv-python torchvision scikit-image pytest - name: Run tests - run: pytest -v tests/test_torch.py + run: pytest -vs tests/test_torch.py From 0616dea591ed906a725cb8e4b3792ad1bd7db835 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 10:56:30 +0200 Subject: [PATCH 14/63] fix tests --- .github/workflows/tests.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index df40da1..b2a3d76 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,16 +53,16 @@ jobs: with: name: "Python wheel" - - name: Install deps and wheel + - name: Install dependencies run: | - pip install tensorflow==${{ matrix.tf-version }} protobuf==3.20.* - pip install --find-links=${{github.workspace}} torchstain + pip install tensorflow==${{ matrix.tf-version }} protobuf==3.20.* opencv-python-headless scikit-image + pip install pytest - - name: Install test dependencies - run: pip install opencv-python scikit-image pytest + - name: Install wheel + run: pip install --find-links=${{github.workspace}} torchstain - name: Run tests - run: pytest -v tests/test_tf.py + run: pytest -vs tests/test_tf.py test-torch: needs: build @@ -86,13 +86,13 @@ jobs: with: name: "Python wheel" - - name: Install deps and wheel wheel + - name: Install dependencies run: | - pip install --index-url https://download.pytorch.org/whl/cpu/torch/ --extra-index-url https://pypi.org/simple/ torch==${{ matrix.pytorch-version }} - pip install --find-links=${{github.workspace}} torchstain + pip install torch==${{ matrix.pytorch-version }} torchvision opencv-python-headless scikit-image + pip install pytest - - name: Install test dependencies - run: pip install --no-dependencies opencv-python torchvision scikit-image pytest + - name: Install wheel + run: pip install --find-links=${{github.workspace}} torchstain - name: Run tests - run: pytest -v tests/test_torch.py + run: pytest -vs tests/test_torch.py From 3bcbd7207ecd54b1263a0f6b3bc12c3cfd3113fc Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 11:12:28 +0200 Subject: [PATCH 15/63] update README --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index b1c7e2e..f831348 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ [![License](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) [![tests](https://github.com/EIDOSLAB/torchstain/workflows/tests/badge.svg)](https://github.com/EIDOSLAB/torchstain/actions) [![Pip Downloads](https://img.shields.io/pypi/dm/torchstain?label=pip%20downloads&logo=python)](https://pypi.org/project/torchstain/) +[![DOI](https://zenodo.org/badge/323590093.svg)](https://zenodo.org/badge/latestdoi/323590093) + + GPU-accelerated stain normalization tools for histopathological images. Compatible with PyTorch, TensorFlow, and Numpy. Normalization algorithms currently implemented: @@ -69,3 +72,23 @@ 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. + + +## Citing + +If you find this software useful for your research, please cite it as: + + +```bibtex +@software{barbano2022torchstain, + author = {Carlo Alberto Barbano and + André Pedersen}, + title = {EIDOSLAB/torchstain: v1.2.0-rc1}, + month = aug, + year = 2022, + publisher = {Zenodo}, + version = {v1.2.0-rc1}, + doi = {10.5281/zenodo.6976410}, + url = {https://doi.org/10.5281/zenodo.6976410} +} +``` From cb99e5e9e69dfb35dd081b283d2cf92917cc1deb Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 13:12:46 +0200 Subject: [PATCH 16/63] fix docstring --- torchstain/tf/utils/percentile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/torchstain/tf/utils/percentile.py b/torchstain/tf/utils/percentile.py index de8c6ac..32f7c67 100644 --- a/torchstain/tf/utils/percentile.py +++ b/torchstain/tf/utils/percentile.py @@ -6,7 +6,6 @@ def percentile(t: tf.Tensor, q: float) -> Union[int, float]: Return the ``q``-th percentile of the flattened input tensor's data. CAUTION: - * Needs PyTorch >= 1.1.0, as ``torch.kthvalue()`` is used. * Values are not interpolated, which corresponds to ``numpy.percentile(..., interpolation="nearest")``. From a4246c26ec9aed2f514849f013b7d78ec2d11071 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 13:14:41 +0200 Subject: [PATCH 17/63] update description --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 46c0d67..8380aa1 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='torchstain', version='1.2.0', - description='Pytorch stain normalization utils', + description='Stain normalization utils for pytorch and tensorflow', long_description=README, long_description_content_type='text/markdown', url='https://github.com/EIDOSlab/torchstain', From 7298fc478743770251d05ab5ec1923cb8c65fea5 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 13:15:30 +0200 Subject: [PATCH 18/63] add python 3.6 --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b2a3d76..ef8d0ae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,7 +38,7 @@ jobs: matrix: # os: [ windows-2019, ubuntu-18.04, macos-11 ] os: [ubuntu-18.04, ubuntu-20.04] - python-version: [ 3.7, 3.8, 3.9 ] + python-version: [ 3.6, 3.7, 3.8, 3.9 ] tf-version: [2.7.0, 2.8.0, 2.9.0] steps: @@ -71,7 +71,7 @@ jobs: matrix: # os: [ windows-2019, ubuntu-18.04, macos-11 ] os: [ubuntu-18.04, ubuntu-20.04] - python-version: [ 3.7, 3.8, 3.9 ] + 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] steps: From 899e41f19a75849e3ad5206d9aae0edce7e31053 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 13:24:53 +0200 Subject: [PATCH 19/63] fix test configurations --- .github/workflows/tests.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ef8d0ae..f5d4d10 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,9 +37,12 @@ jobs: strategy: matrix: # os: [ windows-2019, ubuntu-18.04, macos-11 ] - os: [ubuntu-18.04, ubuntu-20.04] - python-version: [ 3.6, 3.7, 3.8, 3.9 ] + os: [ubuntu-18.04] + python-version: [ 3.7, 3.8, 3.9 ] tf-version: [2.7.0, 2.8.0, 2.9.0] + include: + - python-version: 3.6 + tf-version: 2.6.0 steps: - uses: actions/checkout@v1 @@ -70,9 +73,14 @@ jobs: strategy: matrix: # os: [ windows-2019, ubuntu-18.04, macos-11 ] - os: [ubuntu-18.04, ubuntu-20.04] + os: [ubuntu-18.04] 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] + exclude: + - python-version: 3.6 + pytorch-version: 1.11.0 + - python-version: 3.6 + pytorch-version: 1.12.0 steps: - uses: actions/checkout@v1 From 036d1eec13fc8eb5f56f0b6c7a33b32c58f16948 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 13:30:50 +0200 Subject: [PATCH 20/63] fix test configurations --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f5d4d10..684066f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,8 @@ jobs: python-version: [ 3.7, 3.8, 3.9 ] tf-version: [2.7.0, 2.8.0, 2.9.0] include: - - python-version: 3.6 + - os: ubuntu-18.04 + python-version: 3.6 tf-version: 2.6.0 steps: From 78e42aaf5829bda375eced267034e5e9a67619d9 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 13:36:14 +0200 Subject: [PATCH 21/63] remove python 3.6 from tensorflow tests --- .github/workflows/tests.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 684066f..5ab6cd8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,10 +40,6 @@ jobs: os: [ubuntu-18.04] python-version: [ 3.7, 3.8, 3.9 ] tf-version: [2.7.0, 2.8.0, 2.9.0] - include: - - os: ubuntu-18.04 - python-version: 3.6 - tf-version: 2.6.0 steps: - uses: actions/checkout@v1 From 803c0141c8a7312719b53f39dcf061fcc1e4d93f Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 13:43:00 +0200 Subject: [PATCH 22/63] enable all systems --- .github/workflows/tests.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ab6cd8..13b7f31 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,8 +36,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # os: [ windows-2019, ubuntu-18.04, macos-11 ] - os: [ubuntu-18.04] + os: [ windows-2019, ubuntu-18.04, macos-11 ] python-version: [ 3.7, 3.8, 3.9 ] tf-version: [2.7.0, 2.8.0, 2.9.0] @@ -69,8 +68,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # os: [ windows-2019, ubuntu-18.04, macos-11 ] - os: [ubuntu-18.04] + 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] exclude: From 856051316ce4ffae7e8c0c7679a4468ea5fac918 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 14:23:08 +0200 Subject: [PATCH 23/63] perform all tests for main branch only --- .../workflows/{tests.yml => tests_full.yml} | 4 +- .github/workflows/tests_quick.yml | 86 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) rename .github/workflows/{tests.yml => tests_full.yml} (99%) create mode 100644 .github/workflows/tests_quick.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests_full.yml similarity index 99% rename from .github/workflows/tests.yml rename to .github/workflows/tests_full.yml index 13b7f31..ba105d9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests_full.yml @@ -3,10 +3,10 @@ name: tests on: push: branches: - - '*' + - main pull_request: branches: - - '*' + - main jobs: build: diff --git a/.github/workflows/tests_quick.yml b/.github/workflows/tests_quick.yml new file mode 100644 index 0000000..3e08828 --- /dev/null +++ b/.github/workflows/tests_quick.yml @@ -0,0 +1,86 @@ +name: tests + +on: + push: + branches-ignore: + - main + pull_request: + branches-ignore: + - main + +jobs: + build: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.6 + uses: actions/setup-python@v2 + with: + python-version: 3.6 + + - name: Install dependencies + run: pip install wheel setuptools + + - name: Build wheel + run: python setup.py bdist_wheel + + - name: Upload Python wheel + uses: actions/upload-artifact@v2 + with: + name: Python wheel + path: ${{github.workspace}}/dist/torchstain-*.whl + if-no-files-found: error + + test-tf: + needs: build + runs-on: ubuntu-18.04 + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Download artifact + uses: actions/download-artifact@master + with: + name: "Python wheel" + + - name: Install dependencies + run: | + pip install tensorflow protobuf==3.20.* opencv-python-headless scikit-image + pip install pytest + + - name: Install wheel + run: pip install --find-links=${{github.workspace}} torchstain + + - name: Run tests + run: pytest -vs tests/test_tf.py + + test-torch: + needs: build + runs-on: ubuntu-18.04 + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Download artifact + uses: actions/download-artifact@master + with: + name: "Python wheel" + + - name: Install dependencies + run: | + pip install torch torchvision opencv-python-headless scikit-image + pip install pytest + + - name: Install wheel + run: pip install --find-links=${{github.workspace}} torchstain + + - name: Run tests + run: pytest -vs tests/test_torch.py From 1282a02a2779a0d19daea00a5502da7ffb67ff77 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 14:43:18 +0200 Subject: [PATCH 24/63] update description --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8380aa1..c535b9c 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='torchstain', version='1.2.0', - description='Stain normalization utils for pytorch and tensorflow', + description=' Stain normalization tools for histological analysis and computational pathology', long_description=README, long_description_content_type='text/markdown', url='https://github.com/EIDOSlab/torchstain', From 365a4949d7fb83f8631e324001157e76b74196b1 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Tue, 9 Aug 2022 21:38:27 +0200 Subject: [PATCH 25/63] remove leading space --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c535b9c..d27acf7 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='torchstain', version='1.2.0', - description=' Stain normalization tools for histological analysis and computational pathology', + description='Stain normalization tools for histological analysis and computational pathology', long_description=README, long_description_content_type='text/markdown', url='https://github.com/EIDOSlab/torchstain', From ecf0ade421fabfb77a0b18e714653383d4a046e2 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 09:10:51 +0200 Subject: [PATCH 26/63] add build action --- .github/workflows/build.yaml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..5af1972 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,27 @@ +name: Build + +on: + push: + branches: + - main + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, windows-2019, macOS-11] + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel==2.8.1 + - name: Build wheels + run: python -m cibuildwheel --output-dir dist + + - uses: actions/upload-artifact@v3 + with: + path: ./dist/*.whl \ No newline at end of file From bc7c6a5c4b40bea8eee26f4ffe1a4968642f444f Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 09:29:52 +0200 Subject: [PATCH 27/63] add build&deploy action on release created --- .github/workflows/build.yaml | 42 +++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5af1972..eaa4ff0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,27 +1,43 @@ -name: Build +name: Build and upload to PyPI on: - push: - branches: - - main + release: + types: [created] jobs: build_wheels: - name: Build wheels on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-20.04, windows-2019, macOS-11] + name: Build release + runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v3 + with: + python-version: '3.x' + + - name: Install deps + run: | + pip install setuptools wheel - - name: Install cibuildwheel - run: python -m pip install cibuildwheel==2.8.1 - name: Build wheels - run: python -m cibuildwheel --output-dir dist + run: python setup.py sdist bdist_wheel - uses: actions/upload-artifact@v3 with: - path: ./dist/*.whl \ No newline at end of file + path: ./dist/* + + upload_pypi: + needs: build_wheels + runs-on: ubuntu-18.04 + + steps: + - uses: actions/download-artifact@v3 + with: + name: artifact + path: dist + + - uses: pypa/gh-action-pypi-publish@v1.5.0 + with: + user: __token__ + password: ${{ secrets.torchstain_deploy_token }} + \ No newline at end of file From 0248a936eb886375ef0dda09d348f6c63d4b1a9c Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 09:43:51 +0200 Subject: [PATCH 28/63] add build&deploy action on release created --- .github/workflows/build.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index eaa4ff0..99a671a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,8 +1,12 @@ name: Build and upload to PyPI on: - release: - types: [created] + push: + branches: + main + relase: + types: + - published jobs: build_wheels: @@ -29,6 +33,8 @@ jobs: upload_pypi: needs: build_wheels runs-on: ubuntu-18.04 + + if: github.event_name == 'release' && github.event.action == 'published' steps: - uses: actions/download-artifact@v3 From e588a63398cf4e1077176173659f7bc5f8cea9eb Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 09:44:29 +0200 Subject: [PATCH 29/63] fix typo in action yaml --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 99a671a..11967cb 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,7 +4,7 @@ on: push: branches: main - relase: + release: types: - published From 93fd522e3f75bde17a52b43b277d4f2b83d5478c Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 10:13:18 +0200 Subject: [PATCH 30/63] deploy on tagged version commit --- .github/workflows/build.yaml | 5 +---- .github/workflows/tests_full.yml | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 11967cb..f9d872f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,9 +4,6 @@ on: push: branches: main - release: - types: - - published jobs: build_wheels: @@ -34,7 +31,7 @@ jobs: needs: build_wheels runs-on: ubuntu-18.04 - if: github.event_name == 'release' && github.event.action == 'published' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/download-artifact@v3 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index ba105d9..e550c88 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -11,6 +11,9 @@ on: jobs: build: runs-on: ubuntu-18.04 + + if: startsWith(github.ref, 'refs/tags/v') != true + steps: - uses: actions/checkout@v1 - name: Set up Python 3.6 From 978c84ce57ea0315d28d212fd16d0737b56774a7 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 10:21:47 +0200 Subject: [PATCH 31/63] deploy on tagged version commit --- .github/workflows/build.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f9d872f..df8be46 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -30,7 +30,6 @@ jobs: upload_pypi: needs: build_wheels runs-on: ubuntu-18.04 - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: From 3fbf493adea957707d47e216377a1a4712a9b5ef Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 10:27:18 +0200 Subject: [PATCH 32/63] deploy on tagged version commit --- .github/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index df8be46..f9d872f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -30,6 +30,7 @@ jobs: upload_pypi: needs: build_wheels runs-on: ubuntu-18.04 + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: From a104b345e1fbe29121aa1c10936b28b19c96b88d Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 10:38:38 +0200 Subject: [PATCH 33/63] do not specify main branch for push --- .github/workflows/build.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f9d872f..e6719d7 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,9 +1,7 @@ name: Build and upload to PyPI on: - push: - branches: - main + push jobs: build_wheels: From b2571847b4760c5909085d69e27827d0f0c5b022 Mon Sep 17 00:00:00 2001 From: Carlo Alberto Barbano Date: Wed, 10 Aug 2022 11:03:11 +0200 Subject: [PATCH 34/63] update README [skip ci] --- README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f831348..f777711 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,25 @@ If you find this software useful for your research, please cite it as: @software{barbano2022torchstain, author = {Carlo Alberto Barbano and André Pedersen}, - title = {EIDOSLAB/torchstain: v1.2.0-rc1}, + title = {EIDOSLAB/torchstain: v1.2.0-stable}, month = aug, year = 2022, publisher = {Zenodo}, - version = {v1.2.0-rc1}, - doi = {10.5281/zenodo.6976410}, - url = {https://doi.org/10.5281/zenodo.6976410} + version = {v1.2.0-stable}, + doi = {10.5281/zenodo.6979540}, + url = {https://doi.org/10.5281/zenodo.6979540} +} +``` + +Torchstain was originally developed within the [UNITOPATHO](https://github.com/EIDOSLAB/UNITOPATHO) data collection, which you can cite as: + +```bibtex +@inproceedings{barbano2021unitopatho, + title={UniToPatho, a labeled histopathological dataset for colorectal polyps classification and adenoma dysplasia grading}, + author={Barbano, Carlo Alberto and Perlo, Daniele and Tartaglione, Enzo and Fiandrotti, Attilio and Bertero, Luca and Cassoni, Paola and Grangetto, Marco}, + booktitle={2021 IEEE International Conference on Image Processing (ICIP)}, + pages={76--80}, + year={2021}, + organization={IEEE} } ``` From 97ea7657d507735f53f050d90a2527baed9c8f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 13:47:43 +0100 Subject: [PATCH 35/63] added experimental numpy support for Reinhard --- torchstain/base/normalizers/__init__.py | 1 + torchstain/base/normalizers/reinhard.py | 10 ++++++++ torchstain/numpy/normalizers/__init__.py | 3 ++- torchstain/numpy/normalizers/reinhard.py | 29 +++++++++++++++++++++ torchstain/numpy/utils/__init__.py | 0 torchstain/numpy/utils/rgb2lab.py | 32 ++++++++++++++++++++++++ torchstain/numpy/utils/split.py | 21 ++++++++++++++++ torchstain/numpy/utils/stats.py | 9 +++++++ 8 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 torchstain/base/normalizers/reinhard.py create mode 100644 torchstain/numpy/normalizers/reinhard.py create mode 100644 torchstain/numpy/utils/__init__.py create mode 100644 torchstain/numpy/utils/rgb2lab.py create mode 100644 torchstain/numpy/utils/split.py create mode 100644 torchstain/numpy/utils/stats.py diff --git a/torchstain/base/normalizers/__init__.py b/torchstain/base/normalizers/__init__.py index aceb595..a8f8bb7 100644 --- a/torchstain/base/normalizers/__init__.py +++ b/torchstain/base/normalizers/__init__.py @@ -1,2 +1,3 @@ from .he_normalizer import HENormalizer from .macenko import MacenkoNormalizer +from .reinhard import ReinhardNormalizer diff --git a/torchstain/base/normalizers/reinhard.py b/torchstain/base/normalizers/reinhard.py new file mode 100644 index 0000000..f6bb6ff --- /dev/null +++ b/torchstain/base/normalizers/reinhard.py @@ -0,0 +1,10 @@ +def ReinhardNormalizer(backend='numpy'): + if backend == 'numpy': + from torchstain.numpy.normalizers import NumpyReinhardNormalizer + return NumpyReinhardNormalizer() + elif backend == "torch": + raise NotImplementedError + elif backend == "tensorflow": + raise NotImplementedError + else: + raise Exception(f'Unknown backend {backend}') diff --git a/torchstain/numpy/normalizers/__init__.py b/torchstain/numpy/normalizers/__init__.py index 2220c20..d453cf1 100644 --- a/torchstain/numpy/normalizers/__init__.py +++ b/torchstain/numpy/normalizers/__init__.py @@ -1 +1,2 @@ -from .macenko import NumpyMacenkoNormalizer \ No newline at end of file +from .macenko import NumpyMacenkoNormalizer +from .reinhard import NumpyReinhardNormalizer \ No newline at end of file diff --git a/torchstain/numpy/normalizers/reinhard.py b/torchstain/numpy/normalizers/reinhard.py new file mode 100644 index 0000000..bd36609 --- /dev/null +++ b/torchstain/numpy/normalizers/reinhard.py @@ -0,0 +1,29 @@ +import numpy as np +from torchstain.base.normalizers import HENormalizer +from torchstain.numpy.utils.rgb2lab import rgb2lab +from torchstain.numpy.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 NumpyReinhardNormalizer(HENormalizer): + def __init__(self): + super().__init__() + # unless fit() is applied, no transfer is performed + self.target_mus = np.ones(3) + self.target_stds = np.ones(3) + + def fit(self, target): + self.target_mus, self.target_stds = get_mean_std(target) + + def normalize(self, I): + # convert to LAB + lab = rgb2lab(I) + + # get summary statistics from LAB + mus, stds = get_mean_std(lab) + + # standardize intensities channel-wise and normalize using target means and standard deviations (one for each channel) + return np.dstack([standardize(x, mu_, std_) for x, mu, std_, mu_T, std_T in zip(csplit(lab), mus, stds, target_mus, target_stds)]) diff --git a/torchstain/numpy/utils/__init__.py b/torchstain/numpy/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py new file mode 100644 index 0000000..6706424 --- /dev/null +++ b/torchstain/numpy/utils/rgb2lab.py @@ -0,0 +1,32 @@ +import numpy as np + + +# constant conversion matrices between color spaces +_rgb2lms = np.array([[0.3811, 0.5783, 0.0402], + [0.1967, 0.7244, 0.0782], + [0.0241, 0.1288, 0.8444]]) + +_lms2lab = np.dot( + np.array([[1 / (3 ** 0.5), 0, 0], + [0, 1 / (6 ** 0.5), 0], + [0, 0, 1 / (2 ** 0.5)]]), + np.array([[1, 1, 1], + [1, 1, -2], + [1, -1, 0]]) +) + + +""" +Implementation adapted from https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_conversion/rgb_to_lab.py +""" +def rgb2lab(I): + m, n = I.shape[:2] + + # get LMS from RGB + rgb = np.reshape(I, (m * n, 3)) + lms = np.dot(_rgb2lms, rgb.T) + lms[lms == 0] = np.spacing(1) + + # get LAB from LMS and reshape to three channel image + lab = np.dot(_lms2lab, np.log(lms)) + return np.reshape(lab.T, (m, n, 3)) diff --git a/torchstain/numpy/utils/split.py b/torchstain/numpy/utils/split.py new file mode 100644 index 0000000..6ca19af --- /dev/null +++ b/torchstain/numpy/utils/split.py @@ -0,0 +1,21 @@ +import numpy as np +from torchstain.numpy.utils.rgb2lab import rgb2lab + + +def csplit(I): + return np.dsplit(I, I.shape[-1]) + + +def cmerge(I1, I2, I3): + return np.dstack((I1, I2, I3)) + + +def lab_split(I): + I = rgb2lab(I) + I1, I2, I3 = csplit(I) + return I1 / 2.55, I2 - 128, I3 - 128 + + +def lab_merge(I1, I2, I3): + merged = cmerge((I1 * 2.55, I2 + 128, I3 + 128)) + return np.clip(merged, 0, 255) diff --git a/torchstain/numpy/utils/stats.py b/torchstain/numpy/utils/stats.py new file mode 100644 index 0000000..e420dd7 --- /dev/null +++ b/torchstain/numpy/utils/stats.py @@ -0,0 +1,9 @@ +import numpy as np + + +def get_mean_std(I): + return np.mean(I), np.std(I) + + +def standardize(x, mu, std): + return (x - mu) / std From ba229051afe139adec3583fa4d174ef4ee53cf45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 19:06:13 +0100 Subject: [PATCH 36/63] reinhard works, but required opencv for rgb/lab conversion --- torchstain/numpy/__init__.py | 1 + torchstain/numpy/normalizers/reinhard.py | 31 ++++++++++++++++++------ torchstain/numpy/utils/__init__.py | 4 +++ torchstain/numpy/utils/lab2rgb.py | 28 +++++++++++++++++++++ torchstain/numpy/utils/rgb2lab.py | 31 +++++++++++++++++------- torchstain/numpy/utils/split.py | 10 ++++---- 6 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 torchstain/numpy/utils/lab2rgb.py diff --git a/torchstain/numpy/__init__.py b/torchstain/numpy/__init__.py index e69de29..113e6f1 100644 --- a/torchstain/numpy/__init__.py +++ b/torchstain/numpy/__init__.py @@ -0,0 +1 @@ +from torchstain.numpy import normalizers, utils diff --git a/torchstain/numpy/normalizers/reinhard.py b/torchstain/numpy/normalizers/reinhard.py index bd36609..162d26c 100644 --- a/torchstain/numpy/normalizers/reinhard.py +++ b/torchstain/numpy/normalizers/reinhard.py @@ -1,8 +1,12 @@ import numpy as np from torchstain.base.normalizers import HENormalizer from torchstain.numpy.utils.rgb2lab import rgb2lab +from torchstain.numpy.utils.lab2rgb import lab2rgb +from torchstain.numpy.utils.split import csplit, cmerge, lab_split, lab_merge from torchstain.numpy.utils.stats import get_mean_std, standardize +import cv2 as cv + """ Source code adapted from: https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_normalization/reinhard.py @@ -11,19 +15,32 @@ class NumpyReinhardNormalizer(HENormalizer): def __init__(self): super().__init__() - # unless fit() is applied, no transfer is performed - self.target_mus = np.ones(3) - self.target_stds = np.ones(3) + self.target_mus = None + self.target_stds = None def fit(self, target): - self.target_mus, self.target_stds = get_mean_std(target) + target = target.astype("float32") + lab = rgb2lab(target) + stack_ = np.array([get_mean_std(x) for x in lab_split(lab)]) + self.target_means = stack_[:, 0] + self.target_stds = stack_[:, 1] def normalize(self, I): # convert to LAB lab = rgb2lab(I) + labs = lab_split(lab) # get summary statistics from LAB - mus, stds = get_mean_std(lab) + stack_ = np.array([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) - # standardize intensities channel-wise and normalize using target means and standard deviations (one for each channel) - return np.dstack([standardize(x, mu_, std_) for x, mu, std_, mu_T, std_T in zip(csplit(lab), mus, stds, target_mus, target_stds)]) + # finally, convert back to RGB from LAB + return lab2rgb(lab) diff --git a/torchstain/numpy/utils/__init__.py b/torchstain/numpy/utils/__init__.py index e69de29..24a91f1 100644 --- a/torchstain/numpy/utils/__init__.py +++ b/torchstain/numpy/utils/__init__.py @@ -0,0 +1,4 @@ +from torchstain.numpy.utils.rgb2lab import rgb2lab +from torchstain.numpy.utils.lab2rgb import lab2rgb +from torchstain.numpy.utils.split import * +from torchstain.numpy.utils.stats import * diff --git a/torchstain/numpy/utils/lab2rgb.py b/torchstain/numpy/utils/lab2rgb.py new file mode 100644 index 0000000..4045781 --- /dev/null +++ b/torchstain/numpy/utils/lab2rgb.py @@ -0,0 +1,28 @@ +import numpy as np +from torchstain.numpy.utils.rgb2lab import _lms2lab, _rgb2lms +import cv2 as cv + + +_lms2rgb = np.linalg.inv(_rgb2lms) +_lab2lms = np.linalg.inv(_lms2lab) + + +def lab2rgb(lab): + return cv.cvtColor(np.clip(lab, 0, 255).astype("uint8"), cv.COLOR_LAB2RGB) + + +""" +Implementation adapted from https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_conversion/lab_to_rgb.py +""" +def lab2rgbX(lab): + m, n, c = lab.shape + + # get LMS from LAB + lab = np.reshape(lab, (m * n, c)) + lms = np.dot(_lab2lms, lab.T) + + # get RGB from LMS and reshape to three channel image + lms = np.exp(lms) + lms[lms == np.spacing(1)] = 0 + rgb = np.dot(_lms2rgb, lms) + return np.reshape(rgb.T, (m, n, c)) diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index 6706424..f7d227f 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -1,10 +1,12 @@ import numpy as np +import cv2 as cv +import skimage # constant conversion matrices between color spaces -_rgb2lms = np.array([[0.3811, 0.5783, 0.0402], - [0.1967, 0.7244, 0.0782], - [0.0241, 0.1288, 0.8444]]) +_rgb2lms = np.array([[0.412453, 0.357580, 0.180423], + [0.212671, 0.715160, 0.072169], + [0.019334, 0.119193, 0.950227]]) _lms2lab = np.dot( np.array([[1 / (3 ** 0.5), 0, 0], @@ -16,17 +18,28 @@ ) +def rgb2lab(rgb): + return cv.cvtColor(rgb.astype("uint8"), cv.COLOR_RGB2LAB) + + +def rgb2labX(rgb): + return skimage.color.rgb2lab(rgb) + + """ Implementation adapted from https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_conversion/rgb_to_lab.py """ -def rgb2lab(I): - m, n = I.shape[:2] - +def rgb2labY(rgb): + # rgb = rgb.astype("float32") + m, n, c = rgb.shape + # get LMS from RGB - rgb = np.reshape(I, (m * n, 3)) + rgb = np.reshape(rgb, (m * n, c)) lms = np.dot(_rgb2lms, rgb.T) lms[lms == 0] = np.spacing(1) - # get LAB from LMS and reshape to three channel image + # get LAB from LMS and reshape to 3-channel image lab = np.dot(_lms2lab, np.log(lms)) - return np.reshape(lab.T, (m, n, 3)) + lab = np.reshape(lab.T, (m, n, c)) + + return lab[..., 0] * 255/100, lab[..., 1] + 128, lab[..., 2] + 128 diff --git a/torchstain/numpy/utils/split.py b/torchstain/numpy/utils/split.py index 6ca19af..e2f2b60 100644 --- a/torchstain/numpy/utils/split.py +++ b/torchstain/numpy/utils/split.py @@ -1,21 +1,21 @@ import numpy as np from torchstain.numpy.utils.rgb2lab import rgb2lab +import cv2 as cv def csplit(I): - return np.dsplit(I, I.shape[-1]) + return [I[..., i] for i in range(I.shape[-1])] def cmerge(I1, I2, I3): - return np.dstack((I1, I2, I3)) + return np.stack([I1, I2, I3], axis=-1) def lab_split(I): - I = rgb2lab(I) + I = I.astype("float32") I1, I2, I3 = csplit(I) return I1 / 2.55, I2 - 128, I3 - 128 def lab_merge(I1, I2, I3): - merged = cmerge((I1 * 2.55, I2 + 128, I3 + 128)) - return np.clip(merged, 0, 255) + return cmerge(I1 * 2.55, I2 + 128, I3 + 128) From 657d136e21dc3b0fe034b79e3babd4bd19c6390a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 20:03:40 +0100 Subject: [PATCH 37/63] replaced opencv with scikit-image -> towards numpy only --- torchstain/numpy/utils/lab2rgb.py | 10 +++++- torchstain/numpy/utils/rgb2lab.py | 57 +++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/torchstain/numpy/utils/lab2rgb.py b/torchstain/numpy/utils/lab2rgb.py index 4045781..60a550b 100644 --- a/torchstain/numpy/utils/lab2rgb.py +++ b/torchstain/numpy/utils/lab2rgb.py @@ -1,16 +1,24 @@ import numpy as np from torchstain.numpy.utils.rgb2lab import _lms2lab, _rgb2lms import cv2 as cv +import skimage _lms2rgb = np.linalg.inv(_rgb2lms) _lab2lms = np.linalg.inv(_lms2lab) -def lab2rgb(lab): +def lab2rgbY(lab): return cv.cvtColor(np.clip(lab, 0, 255).astype("uint8"), cv.COLOR_LAB2RGB) +def lab2rgb(lab): + lab[..., 0] /= 2.55 + lab[..., 1] -= 128 + lab[..., 2] -= 128 + return skimage.color.lab2rgb(lab) + + """ Implementation adapted from https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_conversion/lab_to_rgb.py """ diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index f7d227f..12e898a 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -4,10 +4,15 @@ # constant conversion matrices between color spaces -_rgb2lms = np.array([[0.412453, 0.357580, 0.180423], +# https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L392 +_rgb2xyz = np.array([[0.412453, 0.357580, 0.180423], [0.212671, 0.715160, 0.072169], [0.019334, 0.119193, 0.950227]]) +# "D65": {'2': (0.95047, 1., 1.08883), # This was: `lab_ref_white` +# '10': (0.94809667673716, 1, 1.0730513595166162), +# 'R': (0.9532057125493769, 1, 1.0853843816469158)}, + _lms2lab = np.dot( np.array([[1 / (3 ** 0.5), 0, 0], [0, 1 / (6 ** 0.5), 0], @@ -18,19 +23,57 @@ ) -def rgb2lab(rgb): +def rgb2labCV(rgb): return cv.cvtColor(rgb.astype("uint8"), cv.COLOR_RGB2LAB) -def rgb2labX(rgb): - return skimage.color.rgb2lab(rgb) +def rgb2labY(rgb): + rgb = rgb.astype("float32") / 255 + x = skimage.color.rgb2lab(rgb) + + # OpenCV format + x[..., 0] *= 2.55 + x[..., 1] += 128 + x[..., 2] += 128 + return x + + +""" +Implementation adapted from https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 +""" +def rgb2lab(rgb): + # convert rgb -> xyz color domain + arr = rgb.copy() + mask = arr > 0.04045 + arr[mask] = np.power((arr[mask] + 0.055) / 1.055, 2.4) + arr[~mask] /= 12.92 + xyz = np.dot(arr, _rgb2xyz.T.astype(arr.dtype)) + + # convert xyz -> lab color domain + + # scale by CIE XYZ tristimulus values of the reference white point + arr = arr / np.array((0.95047, 1., 1.08883)) + + # Nonlinear distortion and linear transformation + mask = arr > 0.008856 + arr[mask] = np.cbrt(arr[mask]) + arr[~mask] = 7.787 * arr[~mask] + 16. / 116. + + 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) + return np.concatenate([x[..., np.newaxis] for x in [L, a, b]], axis=-1) """ Implementation adapted from https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_conversion/rgb_to_lab.py """ -def rgb2labY(rgb): - # rgb = rgb.astype("float32") +def rgb2lab_old(rgb): + # rgb = rgb.astype("float32") / 255 + rgb = rgb.astype("float32") m, n, c = rgb.shape # get LMS from RGB @@ -42,4 +85,4 @@ def rgb2labY(rgb): lab = np.dot(_lms2lab, np.log(lms)) lab = np.reshape(lab.T, (m, n, c)) - return lab[..., 0] * 255/100, lab[..., 1] + 128, lab[..., 2] + 128 + return lab From c2c46255952e2d76940591d1117f56bb79fa274d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 20:24:04 +0100 Subject: [PATCH 38/63] numpy rgb2lab working --- torchstain/numpy/utils/rgb2lab.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index 12e898a..d38d33f 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -9,6 +9,8 @@ [0.212671, 0.715160, 0.072169], [0.019334, 0.119193, 0.950227]]) +_rgb2lms = _rgb2xyz + # "D65": {'2': (0.95047, 1., 1.08883), # This was: `lab_ref_white` # '10': (0.94809667673716, 1, 1.0730513595166162), # 'R': (0.9532057125493769, 1, 1.0853843816469158)}, @@ -42,6 +44,9 @@ def rgb2labY(rgb): Implementation adapted from https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 """ def rgb2lab(rgb): + # first normalize + rgb = rgb.astype("float32") / 255 + # convert rgb -> xyz color domain arr = rgb.copy() mask = arr > 0.04045 @@ -49,10 +54,9 @@ def rgb2lab(rgb): arr[~mask] /= 12.92 xyz = np.dot(arr, _rgb2xyz.T.astype(arr.dtype)) - # convert xyz -> lab color domain - # scale by CIE XYZ tristimulus values of the reference white point - arr = arr / np.array((0.95047, 1., 1.08883)) + arr = xyz.copy() + arr = arr / np.asarray((0.95047, 1., 1.08883), dtype=xyz.dtype) # Nonlinear distortion and linear transformation mask = arr > 0.008856 @@ -65,6 +69,13 @@ def rgb2lab(rgb): 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 np.concatenate([x[..., np.newaxis] for x in [L, a, b]], axis=-1) From 39bb65a6be5cd5e61b13b8a729e3fc459f567cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 20:25:11 +0100 Subject: [PATCH 39/63] refactored rgb2lab --- torchstain/numpy/utils/rgb2lab.py | 58 +++---------------------------- 1 file changed, 4 insertions(+), 54 deletions(-) diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index d38d33f..f5b012f 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -3,45 +3,15 @@ import skimage -# constant conversion matrices between color spaces -# https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L392 +# constant conversion matrices between color spaces: https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 _rgb2xyz = np.array([[0.412453, 0.357580, 0.180423], [0.212671, 0.715160, 0.072169], [0.019334, 0.119193, 0.950227]]) -_rgb2lms = _rgb2xyz - -# "D65": {'2': (0.95047, 1., 1.08883), # This was: `lab_ref_white` -# '10': (0.94809667673716, 1, 1.0730513595166162), -# 'R': (0.9532057125493769, 1, 1.0853843816469158)}, - -_lms2lab = np.dot( - np.array([[1 / (3 ** 0.5), 0, 0], - [0, 1 / (6 ** 0.5), 0], - [0, 0, 1 / (2 ** 0.5)]]), - np.array([[1, 1, 1], - [1, 1, -2], - [1, -1, 0]]) -) - - -def rgb2labCV(rgb): - return cv.cvtColor(rgb.astype("uint8"), cv.COLOR_RGB2LAB) - - -def rgb2labY(rgb): - rgb = rgb.astype("float32") / 255 - x = skimage.color.rgb2lab(rgb) - - # OpenCV format - x[..., 0] *= 2.55 - x[..., 1] += 128 - x[..., 2] += 128 - return x - - """ -Implementation adapted from https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 +Implementation adapted from: +https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 +https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 """ def rgb2lab(rgb): # first normalize @@ -77,23 +47,3 @@ def rgb2lab(rgb): # finally, get LAB color domain return np.concatenate([x[..., np.newaxis] for x in [L, a, b]], axis=-1) - - -""" -Implementation adapted from https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_conversion/rgb_to_lab.py -""" -def rgb2lab_old(rgb): - # rgb = rgb.astype("float32") / 255 - rgb = rgb.astype("float32") - m, n, c = rgb.shape - - # get LMS from RGB - rgb = np.reshape(rgb, (m * n, c)) - lms = np.dot(_rgb2lms, rgb.T) - lms[lms == 0] = np.spacing(1) - - # get LAB from LMS and reshape to 3-channel image - lab = np.dot(_lms2lab, np.log(lms)) - lab = np.reshape(lab.T, (m, n, c)) - - return lab From a597208215c9022d488642aa19e598bf14d7f88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 20:28:32 +0100 Subject: [PATCH 40/63] removed sklearn + opencv from rgb2lab --- torchstain/numpy/utils/__init__.py | 4 ++-- torchstain/numpy/utils/lab2rgb.py | 22 ++-------------------- torchstain/numpy/utils/rgb2lab.py | 2 -- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/torchstain/numpy/utils/__init__.py b/torchstain/numpy/utils/__init__.py index 24a91f1..f440077 100644 --- a/torchstain/numpy/utils/__init__.py +++ b/torchstain/numpy/utils/__init__.py @@ -1,4 +1,4 @@ -from torchstain.numpy.utils.rgb2lab import rgb2lab -from torchstain.numpy.utils.lab2rgb import lab2rgb +from torchstain.numpy.utils.rgb2lab import * +from torchstain.numpy.utils.lab2rgb import * from torchstain.numpy.utils.split import * from torchstain.numpy.utils.stats import * diff --git a/torchstain/numpy/utils/lab2rgb.py b/torchstain/numpy/utils/lab2rgb.py index 60a550b..0672234 100644 --- a/torchstain/numpy/utils/lab2rgb.py +++ b/torchstain/numpy/utils/lab2rgb.py @@ -1,11 +1,10 @@ import numpy as np -from torchstain.numpy.utils.rgb2lab import _lms2lab, _rgb2lms +from torchstain.numpy.utils.rgb2lab import _rgb2xyz import cv2 as cv import skimage -_lms2rgb = np.linalg.inv(_rgb2lms) -_lab2lms = np.linalg.inv(_lms2lab) +_xyz2rgb = np.linalg.inv(_rgb2xyz) def lab2rgbY(lab): @@ -17,20 +16,3 @@ def lab2rgb(lab): lab[..., 1] -= 128 lab[..., 2] -= 128 return skimage.color.lab2rgb(lab) - - -""" -Implementation adapted from https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_conversion/lab_to_rgb.py -""" -def lab2rgbX(lab): - m, n, c = lab.shape - - # get LMS from LAB - lab = np.reshape(lab, (m * n, c)) - lms = np.dot(_lab2lms, lab.T) - - # get RGB from LMS and reshape to three channel image - lms = np.exp(lms) - lms[lms == np.spacing(1)] = 0 - rgb = np.dot(_lms2rgb, lms) - return np.reshape(rgb.T, (m, n, c)) diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index f5b012f..f367f0d 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -1,6 +1,4 @@ import numpy as np -import cv2 as cv -import skimage # constant conversion matrices between color spaces: https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 From e8907d557167cb5814a92e881d142b91ccbc0f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 20:40:43 +0100 Subject: [PATCH 41/63] lab2rgb working, refactored to pure numpy --- torchstain/numpy/normalizers/reinhard.py | 8 ++++-- torchstain/numpy/utils/lab2rgb.py | 35 +++++++++++++++++++----- torchstain/numpy/utils/rgb2lab.py | 1 + 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/torchstain/numpy/normalizers/reinhard.py b/torchstain/numpy/normalizers/reinhard.py index 162d26c..cdb0332 100644 --- a/torchstain/numpy/normalizers/reinhard.py +++ b/torchstain/numpy/normalizers/reinhard.py @@ -5,7 +5,6 @@ from torchstain.numpy.utils.split import csplit, cmerge, lab_split, lab_merge from torchstain.numpy.utils.stats import get_mean_std, standardize -import cv2 as cv """ Source code adapted from: @@ -42,5 +41,8 @@ def normalize(self, I): # rebuild LAB lab = lab_merge(*result) - # finally, convert back to RGB from LAB - return lab2rgb(lab) + # convert back to RGB from LAB + lab = lab2rgb(lab) + + # rescale to [0, 255] uint8 + return (lab * 255).astype("uint8") diff --git a/torchstain/numpy/utils/lab2rgb.py b/torchstain/numpy/utils/lab2rgb.py index 0672234..0665b63 100644 --- a/torchstain/numpy/utils/lab2rgb.py +++ b/torchstain/numpy/utils/lab2rgb.py @@ -1,18 +1,39 @@ import numpy as np from torchstain.numpy.utils.rgb2lab import _rgb2xyz -import cv2 as cv -import skimage _xyz2rgb = np.linalg.inv(_rgb2xyz) -def lab2rgbY(lab): - return cv.cvtColor(np.clip(lab, 0, 255).astype("uint8"), cv.COLOR_LAB2RGB) - - +""" +Implementation is based on: +https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 +""" def lab2rgb(lab): + # first rescale back from OpenCV format lab[..., 0] /= 2.55 lab[..., 1] -= 128 lab[..., 2] -= 128 - return skimage.color.lab2rgb(lab) + + # convert LAB -> XYZ color domain + L, a, b = lab[..., 0], lab[..., 1], lab[..., 2] + y = (L + 16.) / 116. + x = (a / 500.) + y + z = y - (b / 200.) + + out = np.stack([x, y, z], axis=-1) + + mask = out > 0.2068966 + out[mask] = np.power(out[mask], 3.) + out[~mask] = (out[~mask] - 16.0 / 116.) / 7.787 + + # rescale to the reference white (illuminant) + out *= np.array((0.95047, 1., 1.08883), dtype=out.dtype) + + # convert XYZ -> RGB color domain + arr = out.copy() + arr = np.dot(arr, _xyz2rgb.T) + mask = arr > 0.0031308 + arr[mask] = 1.055 * np.power(arr[mask], 1 / 2.4) - 0.055 + arr[~mask] *= 12.92 + return np.clip(arr, 0, 1) diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index f367f0d..26a11eb 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -6,6 +6,7 @@ [0.212671, 0.715160, 0.072169], [0.019334, 0.119193, 0.950227]]) + """ Implementation adapted from: https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 From b3f7209b5dfaf699d86dc01c06fe95eb5f4c0e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 20:41:36 +0100 Subject: [PATCH 42/63] removed redundant cv2 import --- torchstain/numpy/utils/split.py | 1 - 1 file changed, 1 deletion(-) diff --git a/torchstain/numpy/utils/split.py b/torchstain/numpy/utils/split.py index e2f2b60..8423334 100644 --- a/torchstain/numpy/utils/split.py +++ b/torchstain/numpy/utils/split.py @@ -1,6 +1,5 @@ import numpy as np from torchstain.numpy.utils.rgb2lab import rgb2lab -import cv2 as cv def csplit(I): From 74c22e390d03a92bea899e414de7bda9c5bbaaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 20:43:29 +0100 Subject: [PATCH 43/63] refactored to keep consistent structure --- torchstain/numpy/normalizers/reinhard.py | 1 - torchstain/numpy/utils/lab2rgb.py | 2 -- torchstain/numpy/utils/rgb2lab.py | 2 -- torchstain/numpy/utils/split.py | 4 ---- torchstain/numpy/utils/stats.py | 2 -- 5 files changed, 11 deletions(-) diff --git a/torchstain/numpy/normalizers/reinhard.py b/torchstain/numpy/normalizers/reinhard.py index cdb0332..2a7d874 100644 --- a/torchstain/numpy/normalizers/reinhard.py +++ b/torchstain/numpy/normalizers/reinhard.py @@ -5,7 +5,6 @@ from torchstain.numpy.utils.split import csplit, cmerge, lab_split, lab_merge from torchstain.numpy.utils.stats import get_mean_std, standardize - """ Source code adapted from: https://github.com/DigitalSlideArchive/HistomicsTK/blob/master/histomicstk/preprocessing/color_normalization/reinhard.py diff --git a/torchstain/numpy/utils/lab2rgb.py b/torchstain/numpy/utils/lab2rgb.py index 0665b63..d14f929 100644 --- a/torchstain/numpy/utils/lab2rgb.py +++ b/torchstain/numpy/utils/lab2rgb.py @@ -1,10 +1,8 @@ import numpy as np from torchstain.numpy.utils.rgb2lab import _rgb2xyz - _xyz2rgb = np.linalg.inv(_rgb2xyz) - """ Implementation is based on: https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index 26a11eb..cdcec1e 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -1,12 +1,10 @@ import numpy as np - # constant conversion matrices between color spaces: https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 _rgb2xyz = np.array([[0.412453, 0.357580, 0.180423], [0.212671, 0.715160, 0.072169], [0.019334, 0.119193, 0.950227]]) - """ Implementation adapted from: https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 diff --git a/torchstain/numpy/utils/split.py b/torchstain/numpy/utils/split.py index 8423334..978e9a2 100644 --- a/torchstain/numpy/utils/split.py +++ b/torchstain/numpy/utils/split.py @@ -1,20 +1,16 @@ import numpy as np from torchstain.numpy.utils.rgb2lab import rgb2lab - def csplit(I): return [I[..., i] for i in range(I.shape[-1])] - def cmerge(I1, I2, I3): return np.stack([I1, I2, I3], axis=-1) - def lab_split(I): I = I.astype("float32") 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/numpy/utils/stats.py b/torchstain/numpy/utils/stats.py index e420dd7..b43ebe1 100644 --- a/torchstain/numpy/utils/stats.py +++ b/torchstain/numpy/utils/stats.py @@ -1,9 +1,7 @@ import numpy as np - def get_mean_std(I): return np.mean(I), np.std(I) - def standardize(x, mu, std): return (x - mu) / std From 25f1602053a492acb7118434e6cf1eb70d0c87b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 21:00:10 +0100 Subject: [PATCH 44/63] refactored --- torchstain/numpy/normalizers/reinhard.py | 10 +++++++++- torchstain/numpy/utils/lab2rgb.py | 1 + torchstain/numpy/utils/rgb2lab.py | 3 +-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/torchstain/numpy/normalizers/reinhard.py b/torchstain/numpy/normalizers/reinhard.py index 2a7d874..fbb8d13 100644 --- a/torchstain/numpy/normalizers/reinhard.py +++ b/torchstain/numpy/normalizers/reinhard.py @@ -17,13 +17,21 @@ def __init__(self): self.target_stds = None def fit(self, target): - target = target.astype("float32") + # normalize + target = target.astype("float32") / 255 + + # convert to LAB lab = rgb2lab(target) + + # get summary statistics stack_ = np.array([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.astype("float32") / 255 + # convert to LAB lab = rgb2lab(I) labs = lab_split(lab) diff --git a/torchstain/numpy/utils/lab2rgb.py b/torchstain/numpy/utils/lab2rgb.py index d14f929..ddcd6c0 100644 --- a/torchstain/numpy/utils/lab2rgb.py +++ b/torchstain/numpy/utils/lab2rgb.py @@ -8,6 +8,7 @@ https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 """ def lab2rgb(lab): + lab = lab.astype("float32") # first rescale back from OpenCV format lab[..., 0] /= 2.55 lab[..., 1] -= 128 diff --git a/torchstain/numpy/utils/rgb2lab.py b/torchstain/numpy/utils/rgb2lab.py index cdcec1e..e0edbed 100644 --- a/torchstain/numpy/utils/rgb2lab.py +++ b/torchstain/numpy/utils/rgb2lab.py @@ -11,8 +11,7 @@ https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/color/colorconv.py#L704 """ def rgb2lab(rgb): - # first normalize - rgb = rgb.astype("float32") / 255 + rgb = rgb.astype("float32") # convert rgb -> xyz color domain arr = rgb.copy() From 5970a45df8a5a38615f9ab30023d353eb1282314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 21:00:30 +0100 Subject: [PATCH 45/63] fixed spacing --- tests/test_torch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_torch.py b/tests/test_torch.py index f4363e7..74b6fd2 100644 --- a/tests/test_torch.py +++ b/tests/test_torch.py @@ -9,7 +9,6 @@ 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__) From 5cac2a50cd2929ac31f34790fc318e57e33b0160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 21:00:48 +0100 Subject: [PATCH 46/63] started to add unit test for rgb/lab conversion --- tests/test_color_conv.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/test_color_conv.py diff --git a/tests/test_color_conv.py b/tests/test_color_conv.py new file mode 100644 index 0000000..7261307 --- /dev/null +++ b/tests/test_color_conv.py @@ -0,0 +1,15 @@ +from torchstain.numpy.utils.rgb2lab import rgb2lab +from torchstain.numpy.utils.lab2rgb import lab2rgb +import numpy as np +import cv2 +import os + +def test_rgb_to_lab(): + size = 1024 + curr_file_path = os.path.dirname(os.path.realpath(__file__)) + img = cv2.resize(cv2.cvtColor(cv2.imread(os.path.join(curr_file_path, "../data/source.png")), cv2.COLOR_BGR2RGB), (size, size)) + + reconstructed_img = lab2rgb(rgb2lab(img)) + val = np.mean(np.abs(reconstructed_img - img)) + print("MAE:", val) + assert val < 0.1 From 5738b1faa68290342d29449bb1ebb29b8fefa6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Wed, 23 Nov 2022 21:07:52 +0100 Subject: [PATCH 47/63] added comment of Reinhard to README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f777711..5e40dd6 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ [![DOI](https://zenodo.org/badge/323590093.svg)](https://zenodo.org/badge/latestdoi/323590093) - 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) (experimental numpy support) ## Installation @@ -72,6 +72,7 @@ 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. ## Citing From ee360c650f91a0a8554e76d5eb77d7049311684b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Thu, 24 Nov 2022 09:24:37 +0100 Subject: [PATCH 48/63] added Reinhard to alg table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5e40dd6..c848af6 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) | Algorithm | numpy | torch | tensorflow | |-|-|-|-| | Macenko | ✓ | ✓ | ✓ | +| Reinhard | ✓ | ✗ | ✗ | ## Backend comparison From 1ea3bade79336b092c515c8a6c0ce97eb14ed24c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Thu, 24 Nov 2022 12:06:49 +0100 Subject: [PATCH 49/63] added convenience functions for TF reinhard --- torchstain/tf/utils/split.py | 16 ++++++++++++++++ torchstain/tf/utils/stats.py | 7 +++++++ 2 files changed, 23 insertions(+) create mode 100644 torchstain/tf/utils/split.py create mode 100644 torchstain/tf/utils/stats.py diff --git a/torchstain/tf/utils/split.py b/torchstain/tf/utils/split.py new file mode 100644 index 0000000..226cde0 --- /dev/null +++ b/torchstain/tf/utils/split.py @@ -0,0 +1,16 @@ +import tensorflow as tf +from torchstain.tf.utils.rgb2lab import rgb2lab + +def csplit(I): + return [I[..., i] for i in range(I.shape[-1])] + +def cmerge(I1, I2, I3): + return tf.stack([I1, I2, I3], axis=-1) + +def lab_split(I): + I = tf.cast(I, tf.float32) + 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/tf/utils/stats.py b/torchstain/tf/utils/stats.py new file mode 100644 index 0000000..962afd5 --- /dev/null +++ b/torchstain/tf/utils/stats.py @@ -0,0 +1,7 @@ +import tensorflow as tf + +def get_mean_std(I): + return tf.math.reduce_mean(I), tf.math.reduce_std(I) + +def standardize(x, mu, std): + return (x - mu) / std From fbf06275ca3c7392659a26773f2619ddab829bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Thu, 24 Nov 2022 12:07:03 +0100 Subject: [PATCH 50/63] added RGB/LAB converters for TF --- torchstain/tf/utils/__init__.py | 4 +++ torchstain/tf/utils/rgb2lab.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 torchstain/tf/utils/rgb2lab.py diff --git a/torchstain/tf/utils/__init__.py b/torchstain/tf/utils/__init__.py index 253a3d0..3e2035c 100644 --- a/torchstain/tf/utils/__init__.py +++ b/torchstain/tf/utils/__init__.py @@ -1,3 +1,7 @@ from torchstain.tf.utils.cov import cov from torchstain.tf.utils.percentile import percentile from torchstain.tf.utils.solveLS import solveLS +from torchstain.tf.utils.stats import * +from torchstain.tf.utils.split import * +from torchstain.tf.utils.rgb2lab import * +from torchstain.tf.utils.lab2rgb import * diff --git a/torchstain/tf/utils/rgb2lab.py b/torchstain/tf/utils/rgb2lab.py new file mode 100644 index 0000000..d679a63 --- /dev/null +++ b/torchstain/tf/utils/rgb2lab.py @@ -0,0 +1,45 @@ +import tensorflow as tf + +# constant conversion matrices between color spaces: https://gist.github.com/bikz05/6fd21c812ef6ebac66e1 +_rgb2xyz = tf.constant([[0.412453, 0.357580, 0.180423], + [0.212671, 0.715160, 0.072169], + [0.019334, 0.119193, 0.950227]]) + +_white = tf.constant([0.95047, 1., 1.08883]) + +def rgb2lab(rgb): + arr = tf.cast(rgb, tf.float32) + + # convert rgb -> xyz color domain + mask = arr > 0.04045 + not_mask = tf.math.logical_not(mask) + arr = tf.tensor_scatter_nd_update(arr, tf.where(mask), tf.math.pow((tf.boolean_mask(arr, mask) + 0.055) / 1.055, 2.4)) + arr = tf.tensor_scatter_nd_update(arr, tf.where(not_mask), tf.boolean_mask(arr, not_mask) / 12.92) + + xyz = arr @ tf.cast(tf.transpose(_rgb2xyz), arr.dtype) + + # scale by CIE XYZ tristimulus values of the reference white point + arr = tf.identity(xyz) + arr = arr / tf.cast(_white, xyz.dtype) + + # nonlinear distortion and linear transformation + mask = arr > 0.008856 + not_mask = tf.math.logical_not(mask) + arr = tf.tensor_scatter_nd_update(arr, tf.where(mask), tf.math.pow(tf.boolean_mask(arr, mask), 1.0 / 3.0)) + arr = tf.tensor_scatter_nd_update(arr, tf.where(not_mask), 7.787 * tf.boolean_mask(arr, not_mask) + 16 / 116) + + # 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 tf.stack([L, a, b], axis=-1) From c379f436544c9f10a6ff5d0fed56b10d731fb255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Thu, 24 Nov 2022 12:07:13 +0100 Subject: [PATCH 51/63] forgot lab2rgb --- torchstain/tf/utils/lab2rgb.py | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 torchstain/tf/utils/lab2rgb.py diff --git a/torchstain/tf/utils/lab2rgb.py b/torchstain/tf/utils/lab2rgb.py new file mode 100644 index 0000000..b1d2638 --- /dev/null +++ b/torchstain/tf/utils/lab2rgb.py @@ -0,0 +1,36 @@ +import tensorflow as tf +from torchstain.tf.utils.rgb2lab import _rgb2xyz, _white + +_xyz2rgb = tf.linalg.inv(_rgb2xyz) + +def lab2rgb(lab): + lab = tf.cast(lab, tf.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 = tf.stack([x, y, z], axis=-1) + + # apply boolean transforms + mask = out > 0.2068966 + not_mask = tf.math.logical_not(mask) + out = tf.tensor_scatter_nd_update(out, tf.where(mask), tf.pow(tf.boolean_mask(out, mask), 3)) + out = tf.tensor_scatter_nd_update(out, tf.where(not_mask), (tf.boolean_mask(out, not_mask) - 16 / 116) / 7.787) + + # rescale to the reference white (illuminant) + out = out * tf.cast(_white, out.dtype) + + # convert XYZ -> RGB color domain + arr = tf.identity(out) + arr = arr @ tf.transpose(_xyz2rgb) + mask = arr > 0.0031308 + not_mask = tf.math.logical_not(mask) + arr = tf.tensor_scatter_nd_update(arr, tf.where(mask), 1.055 * tf.pow(tf.boolean_mask(arr, mask), 1 / 2.4) - 0.055) + arr = tf.tensor_scatter_nd_update(arr, tf.where(not_mask), tf.boolean_mask(out, not_mask) * 12.92) + return tf.clip_by_value(arr, 0, 1) From 87dc6356d40ebd9a230058922eb2d4ffeea8f13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Thu, 24 Nov 2022 12:07:48 +0100 Subject: [PATCH 52/63] added TF Reinhard --- torchstain/tf/normalizers/reinhard.py | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 torchstain/tf/normalizers/reinhard.py diff --git a/torchstain/tf/normalizers/reinhard.py b/torchstain/tf/normalizers/reinhard.py new file mode 100644 index 0000000..8a2b601 --- /dev/null +++ b/torchstain/tf/normalizers/reinhard.py @@ -0,0 +1,55 @@ +import tensorflow as tf +from torchstain.base.normalizers import HENormalizer +from torchstain.tf.utils.rgb2lab import rgb2lab +from torchstain.tf.utils.lab2rgb import lab2rgb +from torchstain.tf.utils.split import csplit, cmerge, lab_split, lab_merge +from torchstain.tf.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 TensorFlowReinhardNormalizer(HENormalizer): + def __init__(self): + super().__init__() + self.target_mus = None + self.target_stds = None + + def fit(self, target): + # normalize + target = tf.cast(target, tf.float32) / 255 + + # convert to LAB + lab = rgb2lab(target) + + # get summary statistics + stack_ = tf.convert_to_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 = tf.cast(I, tf.float32) / 255 + + # convert to LAB + lab = rgb2lab(I) + labs = lab_split(lab) + + # get summary statistics from LAB + stack_ = tf.convert_to_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 tf.cast(lab * 255, tf.uint8) From f30f02a9e788586696c47c2a6dc489f70a41126e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Thu, 24 Nov 2022 12:07:59 +0100 Subject: [PATCH 53/63] Reinhard TF works - added it to API --- torchstain/base/normalizers/reinhard.py | 3 ++- torchstain/tf/normalizers/__init__.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/torchstain/base/normalizers/reinhard.py b/torchstain/base/normalizers/reinhard.py index f6bb6ff..96947aa 100644 --- a/torchstain/base/normalizers/reinhard.py +++ b/torchstain/base/normalizers/reinhard.py @@ -5,6 +5,7 @@ def ReinhardNormalizer(backend='numpy'): elif backend == "torch": raise NotImplementedError elif backend == "tensorflow": - raise NotImplementedError + from torchstain.tf.normalizers import TensorFlowReinhardNormalizer + return TensorFlowReinhardNormalizer() else: raise Exception(f'Unknown backend {backend}') diff --git a/torchstain/tf/normalizers/__init__.py b/torchstain/tf/normalizers/__init__.py index f835e4d..0916e1b 100644 --- a/torchstain/tf/normalizers/__init__.py +++ b/torchstain/tf/normalizers/__init__.py @@ -1 +1,2 @@ -from torchstain.tf.normalizers.macenko import TensorFlowMacenkoNormalizer \ No newline at end of file +from torchstain.tf.normalizers.macenko import TensorFlowMacenkoNormalizer +from torchstain.tf.normalizers.reinhard import TensorFlowReinhardNormalizer \ No newline at end of file From 193041d349f37c8c206993768902621d961dd66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Thu, 24 Nov 2022 12:15:22 +0100 Subject: [PATCH 54/63] updated README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c848af6..f26c796 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,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) (experimental numpy support) +- Reinhard et al. [\[2\]](#reference) (only numpy & TensorFlow backend support) ## Installation @@ -50,7 +50,7 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) | Algorithm | numpy | torch | tensorflow | |-|-|-|-| | Macenko | ✓ | ✓ | ✓ | -| Reinhard | ✓ | ✗ | ✗ | +| Reinhard | ✓ | ✗ | ✓ | ## Backend comparison From d029fe47ed28b0f8408d22c6cc92389523d383dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Fri, 25 Nov 2022 14:20:37 +0100 Subject: [PATCH 55/63] moved result.png to data/ --- README.md | 2 +- result.png => data/result.png | Bin 2 files changed, 1 insertion(+), 1 deletion(-) rename result.png => data/result.png (100%) diff --git a/README.md b/README.md index f26c796..75976ea 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ t_to_transform = T(to_transform) norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) ``` -![alt text](result.png) +![alt text](data/result.png) ## Implemented algorithms diff --git a/result.png b/data/result.png similarity index 100% rename from result.png rename to data/result.png From 946db087a87895fc7e641c05b4a2e3042a294445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pedersen?= Date: Fri, 25 Nov 2022 14:22:33 +0100 Subject: [PATCH 56/63] minor refactoring in README --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 75976ea..9047b49 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ [![Pip Downloads](https://img.shields.io/pypi/dm/torchstain?label=pip%20downloads&logo=python)](https://pypi.org/project/torchstain/) [![DOI](https://zenodo.org/badge/323590093.svg)](https://zenodo.org/badge/latestdoi/323590093) - GPU-accelerated stain normalization tools for histopathological images. Compatible with PyTorch, TensorFlow, and Numpy. Normalization algorithms currently implemented: @@ -52,12 +51,10 @@ norm, H, E = normalizer.normalize(I=t_to_transform, stains=True) | Macenko | ✓ | ✓ | ✓ | | Reinhard | ✓ | ✗ | ✓ | - ## Backend comparison Results with 10 runs per size on a Intel(R) Core(TM) i5-8365U CPU @ 1.60GHz - | size | numpy avg. time | torch avg. time | tf avg. time | |--------|-------------------|-------------------|------------------| | 224 | 0.0182s ± 0.0016 | 0.0180s ± 0.0390 | 0.0048s ± 0.0002 | @@ -69,18 +66,15 @@ Results with 10 runs per size on a Intel(R) Core(TM) i5-8365U CPU @ 1.60GHz | 1568 | 1.1935s ± 0.0739 | 0.2590s ± 0.0088 | 0.2531s ± 0.0031 | | 1792 | 1.4523s ± 0.0207 | 0.3402s ± 0.0114 | 0.3080s ± 0.0188 | - ## 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. - ## Citing If you find this software useful for your research, please cite it as: - ```bibtex @software{barbano2022torchstain, author = {Carlo Alberto Barbano and 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 57/63] 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 58/63] 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 59/63] 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 60/63] 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 61/63] 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 62/63] 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 63/63] 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)