Repository: EIDOSLAB/torchstain (language: python)

Issue:
Proper backend support
This PR is the result of the discussion from https://github.com/EIDOSLAB/torchstain/issues/11.

The aim was to be able to use different backends, without the need to install all backends. This is useful as torch/tf often collides, which may hinder people to use staintools out-of-the-box.

What I did was to make subpackages within the main package, totally stratified into three packages: base, tf_backend, torch_backend. The numpy backend is part of the base, as both tf and torch depends on numpy. I also added more CIs to properly verify that it works, but more can be added if necessary. But it should be fine for now.

Last that remains is to simplify imports, as mentioned https://github.com/EIDOSLAB/torchstain/issues/11#issuecomment-1206591731.

**Milestones:**
- [x] Stratified package into three subpackages for the code base and the two backens.
- [x] Added specific CIs to verify that the tool works for tf/torch, without the need for both.
- [ ] Simplify imports.
- [ ] Final refactoring when everything works as intended.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index cf1fa66..5f0385f 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
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)