Repository: EIDOSLAB/torchstain (language: python)

Issue:
Backends
Add support for multiple backends (numpy, torch, tensorflow), discussed in https://github.com/EIDOSLAB/torchstain/issues/11


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index cf1fa66..6d45a3e 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
 [![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/)
 
-Pytorch-compatible normalization tools for histopathological images.
+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))
@@ -12,7 +12,7 @@ Normalization algorithms currently implemented:
 ## Installation
 
 ```bash
-pip3 install torchstain
+pip install torchstain
 ```
 
 ## Example Usage
@@ -31,7 +31,7 @@ T = transforms.Compose([
     transforms.Lambda(lambda x: x*255)
 ])
 
-torch_normalizer = torchstain.MacenkoNormalizer(backend='torch')
+torch_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='torch')
 torch_normalizer.fit(T(target))
 
 t_to_transform = T(to_transform)
diff --git a/example.py b/example.py
index 4a6ea84..6de0f36 100644
--- a/example.py
+++ b/example.py
@@ -10,7 +10,7 @@
 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([
@@ -18,10 +18,10 @@
     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.MacenkoNormalizer(backend='tensorflow')
+tf_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='tensorflow')
 tf_normalizer.fit(T(target))
 
 t_to_transform = T(to_transform)
diff --git a/setup.py b/setup.py
index 6f44501..7eb0b9e 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,5 @@
 import pathlib
-from setuptools import setup, find_packages
+from setuptools import setup, find_packages, find_namespace_packages
 
 HERE = pathlib.Path(__file__).parent
 README = (HERE / "README.md").read_text()
@@ -17,10 +17,12 @@
     packages=find_packages(exclude=('tests')),
     zip_safe=False,
     install_requires=[
-        'torch',
-        'numpy',
-        'tensorflow'
+        'numpy'
     ],
+    extras_require={
+        "tf": ["tensorflow"],
+        "torch": ["torch"],
+    },
     classifiers=[
         'Development Status :: 4 - Beta',
         'Intended Audience :: Developers',
diff --git a/torchstain/__init__.py b/torchstain/__init__.py
index ac31d26..980e3a6 100644
--- a/torchstain/__init__.py
+++ b/torchstain/__init__.py
@@ -1,3 +1,3 @@
 __version__ = '1.1.0'
 
-from torchstain.normalizers.macenko_normalizer import MacenkoNormalizer
+from torchstain.base import normalizers
\ No newline at end of file
diff --git a/torchstain/base/__init__.py b/torchstain/base/__init__.py
new file mode 100644
index 0000000..d66541f
--- /dev/null
+++ b/torchstain/base/__init__.py
@@ -0,0 +1,1 @@
+from torchstain.base import normalizers
\ No newline at end of file
diff --git a/torchstain/base/normalizers/__init__.py b/torchstain/base/normalizers/__init__.py
new file mode 100644
index 0000000..aceb595
--- /dev/null
+++ b/torchstain/base/normalizers/__init__.py
@@ -0,0 +1,2 @@
+from .he_normalizer import HENormalizer
+from .macenko import MacenkoNormalizer
diff --git a/torchstain/normalizers/he_normalizer.py b/torchstain/base/normalizers/he_normalizer.py
similarity index 100%
rename from torchstain/normalizers/he_normalizer.py
rename to torchstain/base/normalizers/he_normalizer.py
diff --git a/torchstain/base/normalizers/macenko.py b/torchstain/base/normalizers/macenko.py
new file mode 100644
index 0000000..2c0c988
--- /dev/null
+++ b/torchstain/base/normalizers/macenko.py
@@ -0,0 +1,12 @@
+def MacenkoNormalizer(backend='torch'):
+    if backend == 'numpy':
+        from torchstain.numpy.normalizers import NumpyMacenkoNormalizer
+        return NumpyMacenkoNormalizer()
+    elif backend == "torch":
+        from torchstain.torch.normalizers.macenko import TorchMacenkoNormalizer
+        return TorchMacenkoNormalizer()
+    elif backend == "tensorflow":
+        from torchstain.tf.normalizers.macenko import TensorFlowMacenkoNormalizer
+        return TensorFlowMacenkoNormalizer()
+    else:
+        raise Exception(f'Unknown backend {backend}')
diff --git a/torchstain/normalizers/__init__.py b/torchstain/normalizers/__init__.py
deleted file mode 100644
index 4b24415..0000000
--- a/torchstain/normalizers/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-import torchstain.normalizers.macenko_normalizer
diff --git a/torchstain/normalizers/macenko_normalizer.py b/torchstain/normalizers/macenko_normalizer.py
deleted file mode 100644
index 266b550..0000000
--- a/torchstain/normalizers/macenko_normalizer.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from .numpy_macenko_normalizer import NumpyMacenkoNormalizer
-from .torch_macenko_normalizer import TorchMacenkoNormalizer
-from .tensorflow_macenko_normalizer import TensorFlowMacenkoNormalizer
-
-def MacenkoNormalizer(backend='torch'):
-    if backend == 'numpy':
-        return NumpyMacenkoNormalizer()
-    elif backend == "tensorflow":
-    	return TensorFlowMacenkoNormalizer()
-    elif backend == "torch":
-    	return TorchMacenkoNormalizer()
-    else:
-    	raise Exception(f'Unknown backend {backend}')
diff --git a/torchstain/numpy/__init__.py b/torchstain/numpy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/torchstain/numpy/normalizers/__init__.py b/torchstain/numpy/normalizers/__init__.py
new file mode 100644
index 0000000..2220c20
--- /dev/null
+++ b/torchstain/numpy/normalizers/__init__.py
@@ -0,0 +1,1 @@
+from .macenko import NumpyMacenkoNormalizer
\ No newline at end of file
diff --git a/torchstain/normalizers/numpy_macenko_normalizer.py b/torchstain/numpy/normalizers/macenko.py
similarity index 97%
rename from torchstain/normalizers/numpy_macenko_normalizer.py
rename to torchstain/numpy/normalizers/macenko.py
index f32d702..37dac55 100644
--- a/torchstain/normalizers/numpy_macenko_normalizer.py
+++ b/torchstain/numpy/normalizers/macenko.py
@@ -1,5 +1,5 @@
 import numpy as np
-from torchstain.normalizers.he_normalizer import HENormalizer
+from torchstain.base.normalizers import HENormalizer
 
 """
 Source code adapted from: https://github.com/schaugf/HEnorm_python
@@ -16,7 +16,7 @@ def __init__(self):
 
     def __convert_rgb2od(self, I, Io=240, beta=0.15):
         # calculate optical density
-        OD = -np.log((I.astype(float)+1)/Io)
+        OD = -np.log((I.astype(np.float)+1)/Io)
 
         # remove transparent pixels
         ODhat = OD[~np.any(OD < beta, axis=1)]
diff --git a/torchstain/tf/__init__.py b/torchstain/tf/__init__.py
new file mode 100644
index 0000000..edaf586
--- /dev/null
+++ b/torchstain/tf/__init__.py
@@ -0,0 +1,1 @@
+from torchstain.tf import normalizers, utils
diff --git a/torchstain/tf/normalizers/__init__.py b/torchstain/tf/normalizers/__init__.py
new file mode 100644
index 0000000..f835e4d
--- /dev/null
+++ b/torchstain/tf/normalizers/__init__.py
@@ -0,0 +1,1 @@
+from torchstain.tf.normalizers.macenko import TensorFlowMacenkoNormalizer
\ No newline at end of file
diff --git a/torchstain/normalizers/tensorflow_macenko_normalizer.py b/torchstain/tf/normalizers/macenko.py
similarity index 96%
rename from torchstain/normalizers/tensorflow_macenko_normalizer.py
rename to torchstain/tf/normalizers/macenko.py
index 90ea859..e9a69ce 100644
--- a/torchstain/normalizers/tensorflow_macenko_normalizer.py
+++ b/torchstain/tf/normalizers/macenko.py
@@ -1,6 +1,6 @@
 import tensorflow as tf
-from torchstain.normalizers.he_normalizer import HENormalizer
-from torchstain.utils import cov, percentile, cov_tf, percentile_tf, solveLS
+from torchstain.base.normalizers.he_normalizer import HENormalizer
+from torchstain.tf.utils import cov_tf, percentile_tf, solveLS
 import numpy as np
 import tensorflow.keras.backend as K
 
diff --git a/torchstain/utils/solveLS.py b/torchstain/tf/normalizers/solveLS.py
similarity index 99%
rename from torchstain/utils/solveLS.py
rename to torchstain/tf/normalizers/solveLS.py
index c3daf79..ee074aa 100644
--- a/torchstain/utils/solveLS.py
+++ b/torchstain/tf/normalizers/solveLS.py
@@ -1,6 +1,5 @@
 import tensorflow as tf
 
-
 def solveLS(A, B):
     q_full, _ = tf.linalg.qr(A, full_matrices=True)
     ret1 = tf.linalg.lstsq(A, B)
diff --git a/torchstain/tf/utils/__init__.py b/torchstain/tf/utils/__init__.py
new file mode 100644
index 0000000..1c65f31
--- /dev/null
+++ b/torchstain/tf/utils/__init__.py
@@ -0,0 +1,3 @@
+from torchstain.tf.utils.cov import cov_tf
+from torchstain.tf.utils.percentile import percentile_tf
+from torchstain.tf.normalizers.solveLS import solveLS
diff --git a/torchstain/utils/cov.py b/torchstain/tf/utils/cov.py
similarity index 55%
rename from torchstain/utils/cov.py
rename to torchstain/tf/utils/cov.py
index 16dec9f..c0ce049 100644
--- a/torchstain/utils/cov.py
+++ b/torchstain/tf/utils/cov.py
@@ -1,15 +1,5 @@
-import torch
 import tensorflow as tf
 
-def cov(x):
-    """
-    https://en.wikipedia.org/wiki/Covariance_matrix
-    """
-    E_x = x.mean(dim=1)
-    x = x - E_x[:, None]
-    return torch.mm(x, x.T) / (x.size(1) - 1)
-
-
 def cov_tf(x):
     """
     https://en.wikipedia.org/wiki/Covariance_matrix
diff --git a/torchstain/tf/utils/percentile.py b/torchstain/tf/utils/percentile.py
new file mode 100644
index 0000000..c6697dc
--- /dev/null
+++ b/torchstain/tf/utils/percentile.py
@@ -0,0 +1,18 @@
+from typing import Union
+import tensorflow as tf
+
+def percentile_tf(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")``.
+
+    :param t: Input tensor.
+    :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)]
diff --git a/torchstain/torch/__init__.py b/torchstain/torch/__init__.py
new file mode 100644
index 0000000..fef6329
--- /dev/null
+++ b/torchstain/torch/__init__.py
@@ -0,0 +1,1 @@
+from torchstain.torch import normalizers, utils
\ No newline at end of file
diff --git a/torchstain/torch/normalizers/__init__.py b/torchstain/torch/normalizers/__init__.py
new file mode 100644
index 0000000..febcd90
--- /dev/null
+++ b/torchstain/torch/normalizers/__init__.py
@@ -0,0 +1,1 @@
+from torchstain.torch.normalizers.macenko import TorchMacenkoNormalizer
diff --git a/torchstain/normalizers/torch_macenko_normalizer.py b/torchstain/torch/normalizers/macenko.py
similarity index 97%
rename from torchstain/normalizers/torch_macenko_normalizer.py
rename to torchstain/torch/normalizers/macenko.py
index 8a0e8bc..710b7e3 100644
--- a/torchstain/normalizers/torch_macenko_normalizer.py
+++ b/torchstain/torch/normalizers/macenko.py
@@ -1,6 +1,6 @@
 import torch
-from torchstain.normalizers.he_normalizer import HENormalizer
-from torchstain.utils import cov, percentile
+from torchstain.base.normalizers.he_normalizer import HENormalizer
+from torchstain.torch.utils import cov, percentile
 
 """
 Source code ported from: https://github.com/schaugf/HEnorm_python
diff --git a/torchstain/torch/utils/__init__.py b/torchstain/torch/utils/__init__.py
new file mode 100644
index 0000000..5e0de3e
--- /dev/null
+++ b/torchstain/torch/utils/__init__.py
@@ -0,0 +1,2 @@
+from torchstain.torch.utils.cov import cov
+from torchstain.torch.utils.percentile import percentile
diff --git a/torchstain/torch/utils/cov.py b/torchstain/torch/utils/cov.py
new file mode 100644
index 0000000..5a2d3c1
--- /dev/null
+++ b/torchstain/torch/utils/cov.py
@@ -0,0 +1,9 @@
+import torch
+
+def cov(x):
+    """
+    https://en.wikipedia.org/wiki/Covariance_matrix
+    """
+    E_x = x.mean(dim=1)
+    x = x - E_x[:, None]
+    return torch.mm(x, x.T) / (x.size(1) - 1)
diff --git a/torchstain/utils/percentile.py b/torchstain/torch/utils/percentile.py
similarity index 57%
rename from torchstain/utils/percentile.py
rename to torchstain/torch/utils/percentile.py
index 5bb263b..188f1bb 100644
--- a/torchstain/utils/percentile.py
+++ b/torchstain/torch/utils/percentile.py
@@ -1,6 +1,5 @@
 import torch
 from typing import Union
-import tensorflow as tf
 
 """
 Author: https://gist.github.com/spezold/42a451682422beb42bc43ad0c0967a30
@@ -23,20 +22,3 @@ def percentile(t: torch.tensor, q: float) -> Union[int, float]:
     # so that ``round()`` returns an integer, even if q is a np.float32.
     k = 1 + round(.01 * float(q) * (t.numel() - 1))
     return t.view(-1).kthvalue(k).values
-
-
-def percentile_tf(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")``.
-
-    :param t: Input tensor.
-    :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)]
diff --git a/torchstain/utils/__init__.py b/torchstain/utils/__init__.py
deleted file mode 100644
index 2318156..0000000
--- a/torchstain/utils/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from torchstain.utils.cov import cov, cov_tf
-from torchstain.utils.percentile import percentile, percentile_tf
-from torchstain.utils.solveLS import solveLS


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index cf18d53..3849cee 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -19,7 +19,7 @@ jobs:
           python-version: 3.6
 
       - name: Install dependencies
-        run: pip install wheel setuptools torch tensorflow numpy
+        run: pip install wheel setuptools
 
       - name: Build wheel
         run: python setup.py bdist_wheel
@@ -46,20 +46,82 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
 
-      - name: Install dependencies
-        run: pip install wheel setuptools torch tensorflow numpy
+      - 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 matplotlib torchvision scikit-image pytest
+        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 ]
+
+    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 wheel
-        run: pip install --find-links=${{github.workspace}} torchstain
+      - name: Install deps and wheel
+        run: |
+          pip install tensorflow
+          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
+
+  test-torch:
+    needs: build
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ windows-2019, ubuntu-18.04, macos-11 ]
+        python-version: [ 3.7 ]
+
+    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 wheel
         run: |
-          pytest -v tests
+          pip install 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_torch_normalizer.py
diff --git a/tests/test_normalizers.py b/tests/test_normalizers.py
index e3da989..5769b96 100644
--- a/tests/test_normalizers.py
+++ b/tests/test_normalizers.py
@@ -1,6 +1,5 @@
 import os
 import cv2
-import matplotlib.pyplot as plt
 import torchstain
 import torch
 from torchvision import transforms
@@ -8,39 +7,39 @@
 from skimage.metrics import structural_similarity as ssim
 import numpy as np
 
-
-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.MacenkoNormalizer(backend='numpy')
-normalizer.fit(target)
-
-torch_normalizer = torchstain.MacenkoNormalizer(backend='torch')
-torch_normalizer.fit(T(target))
-
-tf_normalizer = torchstain.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)
+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_normalizer.py
new file mode 100644
index 0000000..c67d8e9
--- /dev/null
+++ b/tests/test_tf_normalizer.py
@@ -0,0 +1,35 @@
+import os
+import cv2
+import torchstain
+import tensorflow as tf
+import time
+from skimage.metrics import structural_similarity as ssim
+import numpy as np
+
+def test_normalize_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(np.moveaxis(x, -1, 0).astype("float32"))  #  * 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)
+
+    tf_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='tensorflow')
+    tf_normalizer.fit(T(target))
+
+    # transform
+    result_numpy, _, _ = normalizer.normalize(I=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_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_normalizer.py b/tests/test_torch_normalizer.py
new file mode 100644
index 0000000..aa493fc
--- /dev/null
+++ b/tests/test_torch_normalizer.py
@@ -0,0 +1,39 @@
+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_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.MacenkoNormalizer(backend='numpy')
+    normalizer.fit(target)
+
+    torch_normalizer = torchstain.normalizers.MacenkoNormalizer(backend='torch')
+    torch_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)
+
+    # 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)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 0cf3912..266215e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,19 +1,19 @@
 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.utils.cov(torch.tensor(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, method='nearest')
-    p_t = torchstain.utils.percentile(torch.tensor(x), p)
+    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)