diff --git a/deeptrack/noises.py b/deeptrack/noises.py index 2eb937724..eaaa5afe1 100644 --- a/deeptrack/noises.py +++ b/deeptrack/noises.py @@ -1,5 +1,4 @@ -""" -Features for introducing noise to images. +"""Features for introducing noise to images. This module provides classes to add various types of noise to images, including constant offsets, Gaussian noise, and Poisson-distributed noise. @@ -63,14 +62,43 @@ class Noise(Feature): """Base abstract noise class.""" -#TODO ***MG*** revise Background - torch, typing, docstring, unit test class Background(Noise): - """Adds a constant value to an image + """Add a constant value to an image. Parameters ---------- - offset : float - The value to add to the image + offset: float + The value to add to the image. + **kwargs: Any + Additional keyword arguments passed to the parent `Noise` class. + + Methods + ------- + get( + image: np.ndarray, torch.Tensor, or Image, + offset: float, + **kwargs, + ) -> np.ndarray, torch.Tensor, or Image + Adds the constant offset to the input image. + + Examples + -------- + >>> import deeptrack as dt + + Create an input image with zeros: + >>> import numpy as np + >>> + >>> input_image = np.zeros((2,2)) + + Define the Background noise feature with offset 0.5: + >>> noise = dt.Background(offset=0.5) + + Apply the noise to the input image and print the resulting image: + >>> output_image = noise.resolve(input_image) + >>> print(output_image) + [[0.5 0.5] + [0.5 0.5]] + """ def __init__( @@ -78,6 +106,15 @@ def __init__( offset: PropertyLike[float], **kwargs: Any, ): + """Initialize the Background noise feature. + + Parameters + ---------- + offset: PropertyLike[float] + The constant value to be added to the image. + **kwargs: Any + Additional arguments passed to the parent `Noise` class. + """ super().__init__(offset=offset, **kwargs) def get( @@ -86,23 +123,76 @@ def get( offset: float, **kwargs: Any, ) -> NDArray[Any] | torch.Tensor | Image: + """Add the given offset to the image. - return image + offset + Parameters + ---------- + image: np.ndarray, torch.Tensor, or Image + The input image. + offset: float + The value to add to the image. + Returns + ------- + np.ndarray, torch.Tensor, or Image + The image with offset added. + """ + + return image + offset Offset = Background -#TODO ***JH*** revise Gaussian - torch, typing, docstring, unit test class Gaussian(Noise): - """Adds IID Gaussian noise to an image. + """Add IID Gaussian noise to an image. + + Gaussian noise is sampled from a Gaussian distribution and added pixel-wise + to the input image. Parameters ---------- - mu : float + mu: float The mean of the Gaussian distribution. - sigma : float + sigma: float The standard deviation of the Gaussian distribution. + + Notes + ----- + If the backend is NumPy, the calculations use NumPy-compatible functions, + and the output will be a np.array. If the backend is PyTorch, the + calculations use PyTorch-compatible functions, and the output will be a + torch.Tensor. + + Methods + ------- + get( + image: np.ndarray, torch.Tensor, or Image, + snr: float, + background: float, + max_val: float, optional, + **kwargs, + ) -> np.ndarray, torch.Tensor, or Image + Returns an image with Gaussian noise added. + + Examples + -------- + Add Gaussian noise to an image. + >>> import deeptrack as dt + + Create an input image with constant values: + >>> import numpy as np + >>> + >>> input_image = np.ones((2,2)) * 3 + + Define the Gaussian noise feature with mean 1 and standard deviation 0.1: + >>> noise = dt.Gaussian(mu=1, sigma=0.1) + + Apply the noise to the input image and print the resulting image: + >>> output_image = noise.resolve(input_image) + >>> print(output_image) + [[4.01965863 4.20688642] + [4.02184982 3.87875873]] + """ def __init__( @@ -111,7 +201,6 @@ def __init__( sigma: PropertyLike[float] = 1, **kwargs: Any, ): - super().__init__(mu=mu, sigma=sigma, **kwargs) def get( @@ -122,21 +211,69 @@ def get( **kwargs: Any, ) -> NDArray[Any] | torch.Tensor | Image: - noisy_image = mu + image + np.random.randn(*image.shape) * sigma + # For a Numpy backend. + if self.get_backend() == "numpy": + noisy_image = mu + image + np.random.randn(*image.shape) * sigma + + # For a Torch backend. + elif self.get_backend() == "torch": + noisy_image = mu + image + torch.randn(*image.shape) * sigma return noisy_image -#TODO ***JH*** revise ComplexGaussian - torch, typing, docstring, unit test class ComplexGaussian(Noise): - """Adds complex-valued IID Gaussian noise to an image. + """Add complex-valued IID Gaussian noise to an image. + + Complex Gaussian noise is sampled by combining two independent Gaussian + distributions for real and imaginary values and is then added pixel-wise + to the input image. Parameters ---------- - mu : float + mu: float The mean of the Gaussian distribution. - sigma : float + sigma: float The standard deviation of the Gaussian distribution. + + Notes + ----- + If the backend is NumPy, the calculations use NumPy-compatible functions, + and the output will be a np.array. If the backend is PyTorch, the + calculations use PyTorch-compatible functions, and the output will be a + torch.Tensor. + + Methods + ------- + get( + image: np.ndarray, torch.Tensor, or Image, + snr: float, + background: float, + max_val: float, optional, + **kwargs, + ) -> np.ndarray, torch.Tensor, or Image + Returns an image with complex Gaussian noise added. + + Examples + -------- + Add complex Gaussian noise to an image. + + >>> import deeptrack as dt + + Create an input image with constant values: + >>> import numpy as np + >>> + >>> input_image = np.ones((2,2)) * 3 + + Define the Gaussian noise feature with mean 1 and standard deviation 0.1: + >>> noise = dt.ComplexGaussian(mu=1, sigma=0.1) + + Apply the noise to the input image and print the resulting image: + >>> output_image = noise.resolve(input_image) + >>> print(output_image) + [[3.79975648-0.06967551j 4.09943404+0.06499738j] + [3.99886747-0.23549974j 4.15725117-0.07847024j]] + """ def __init__( @@ -145,7 +282,6 @@ def __init__( sigma: PropertyLike[float] = 1, **kwargs: Any, ): - super().__init__(mu=mu, sigma=sigma, **kwargs) def get( @@ -156,28 +292,78 @@ def get( **kwargs: Any, ) -> NDArray[Any] | torch.Tensor | Image: - real_noise = np.random.randn(*image.shape) - imag_noise = np.random.randn(*image.shape) * 1j - noisy_image = mu + image + (real_noise + imag_noise) * sigma - + # For a Numpy backend. + if self.get_backend() == "numpy": + real_noise = np.random.randn(*image.shape) + imag_noise = np.random.randn(*image.shape) * 1j + noisy_image = mu + image + (real_noise + imag_noise) * sigma + + # For a Torch backend. + elif self.get_backend() == "torch": + real_noise = torch.randn(*image.shape) + imag_noise = torch.randn(*image.shape) * 1j + noisy_image = mu + image + (real_noise + imag_noise) * sigma + return noisy_image -#TODO ***AL*** revise Poisson - torch, typing, docstring, unit test class Poisson(Noise): - """Adds Poisson-distributed noise to an image. + """Add Poisson-distributed noise to an image. + + Poisson noise is sampled and added pixel-wise depending on the + intensity of the pixel in the original image to achieve a desired + signal-to-noise ratio `snr`. Parameters ---------- - snr : float + snr: float Signal-to-noise ratio of the final image. The signal is determined by the peak value of the image. - background : float + background: float Value to be be used as the background. This is used to calculate the signal of the image. - max_val : float, optional + max_val: float, optional Maximum allowable value to prevent overflow in noise computation. Default is 1e8. + + Notes + ----- + If the backend is NumPy, the calculations use NumPy-compatible functions, + and the output will be a np.array. If the backend is PyTorch, the + calculations use PyTorch-compatible functions, and the output will be a + torch.Tensor. + + Methods + ------- + get( + image: np.ndarray, torch.Tensor, or Image, + snr: float, + background: float, + max_val: float, optional, + **kwargs, + ) -> np.ndarray, torch.Tensor, or Image + Returns an image with Poisson noise added. + + Examples + -------- + Add Poisson noise to an image. + + >>> import deeptrack as dt + + Create an input image with ones: + >>> import numpy as np + >>> + >>> input_image = np.ones((2,2)) + + Define the Poisson noise feature with a low SNR: + >>> noise = dt.Poisson(snr=1) + + Apply the noise to the input image and print the resulting image: + >>> output_image = noise.resolve(input_image) + >>> print(output_image) + [[2. 1.] + [0. 4.]] + """ def __init__( @@ -188,7 +374,6 @@ def __init__( max_val: PropertyLike[float] = 1e8, **kwargs, ): - super().__init__( *args, snr=snr, @@ -206,18 +391,43 @@ def get( **kwargs: Any, ) -> NDArray[Any] | torch.Tensor | Image: - image[image < 0] = 0 - image_max = np.max(image) - peak = np.abs(image_max - background) - - rescale = snr ** 2 / peak ** 2 - rescale = np.clip(rescale, 1e-10, max_val / np.abs(image_max)) - try: - noisy_image = Image(np.random.poisson(image * rescale) / rescale) - noisy_image.merge_properties_from(image) # TODO Should only be done if input is Image! - return noisy_image - except ValueError: - raise ValueError( - "NumPy poisson function errored due to too large value. " - "Set max_val in dt.Poisson to a lower value to fix." + # For a numpy backend. + if self.get_backend() == "numpy": + image[image < 0] = 0 + image_max = np.max(image) + peak = np.abs(image_max - background) + + rescale = snr ** 2 / peak ** 2 + rescale = np.clip( + rescale, 1e-10, max_val / np.abs(image_max) + ) + try: + noisy_image = Image( + np.random.poisson(image * rescale) / rescale + ) + noisy_image.merge_properties_from(image) + return noisy_image + except ValueError: + raise ValueError( + "NumPy poisson function errored due to too large value. " + "Set max_val in dt.Poisson to a lower value to fix." + ) + + # For a Torch backend. + elif self.get_backend() == "torch": + image = torch.clamp(image, min=0) + image_max = torch.max(image) + peak = torch.abs(image_max - background) + + rescale = snr ** 2 / peak ** 2 + rescale = torch.clamp( + rescale, min=1e-10, max=max_val / torch.abs(image_max) ) + try: + noisy_image = torch.poisson(image * rescale) / rescale + return noisy_image + except ValueError: + raise ValueError( + "Torch Poisson function errored due to too large value. " + "Set max_val in dt.Poisson to a lower value to fix." + ) \ No newline at end of file diff --git a/deeptrack/tests/test_noises.py b/deeptrack/tests/test_noises.py index 964e0411a..0e15e800a 100644 --- a/deeptrack/tests/test_noises.py +++ b/deeptrack/tests/test_noises.py @@ -9,46 +9,88 @@ from deeptrack.image import Image from deeptrack import noises +from deeptrack.backend import TORCH_AVAILABLE, xp +from deeptrack.tests import BackendTestBase + +if TORCH_AVAILABLE: + import torch + +class TestNoises_NumPy(BackendTestBase): + BACKEND = "numpy" + + @property + def array_type(self): + if self.BACKEND == "numpy": + return np.ndarray + elif self.BACKEND == "torch": + return torch.Tensor + else: + raise ValueError(f"Unsupported backend: {self.BACKEND}") -class TestNoises(unittest.TestCase): def test_Offset(self): noise = noises.Offset(offset=0.5) - input_image = Image(np.zeros((256, 256))) + input_image = Image(xp.zeros((256, 256))) output_image = noise.resolve(input_image) - self.assertIsInstance(output_image, np.ndarray) + + self.assertIsInstance(output_image, self.array_type) self.assertEqual(output_image.shape, (256, 256)) - self.assertTrue(np.all(np.array(output_image) == 0.5)) + self.assertTrue(xp.all(xp.asarray(output_image) == 0.5)) def test_Background(self): + # Test with DeepTrack Image noise = noises.Background(offset=0.5) - input_image = Image(np.zeros((256, 256))) + input_image = Image(xp.zeros((256, 256))) output_image = noise.resolve(input_image) - self.assertIsInstance(output_image, np.ndarray) + + self.assertIsInstance(output_image, self.array_type) self.assertEqual(output_image.shape, (256, 256)) - self.assertTrue(np.all(np.array(output_image) == 0.5)) + self.assertTrue(xp.all(xp.asarray(output_image) == 0.5)) + + # Test with arrays + noise = noises.Background(offset=0.5) + input_image = xp.ones((10, 10)) + output_image = noise.resolve(input_image) + + self.assertIsInstance(output_image, self.array_type) + self.assertEqual(output_image.shape, (10, 10)) + self.assertTrue(xp.all(xp.asarray(output_image) == 1.5)) def test_Gaussian(self): noise = noises.Gaussian(mu=0.1, sigma=0.05) - input_image = Image(np.zeros((256, 256))) + input_image = Image(xp.zeros((256, 256))) output_image = noise.resolve(input_image) - self.assertIsInstance(output_image, np.ndarray) + + self.assertIsInstance(output_image, self.array_type) self.assertEqual(output_image.shape, (256, 256)) def test_ComplexGaussian(self): noise = noises.ComplexGaussian(mu=0.1, sigma=0.05) - input_image = Image(np.zeros((256, 256))) + input_image = Image(xp.zeros((256, 256))) output_image = noise.resolve(input_image) - self.assertIsInstance(output_image, np.ndarray) + + self.assertIsInstance(output_image, self.array_type) self.assertEqual(output_image.shape, (256, 256)) - self.assertTrue(np.iscomplexobj(output_image)) + self.assertTrue(xp.any(output_image.imag != 0)) + + if self.BACKEND == "numpy": + self.assertTrue(np.iscomplexobj(output_image)) + elif self.BACKEND == "torch": + self.assertTrue(torch.is_complex(output_image)) def test_Poisson(self): noise = noises.Poisson(snr=20) - input_image = Image(np.ones((256, 256)) * 0.1) + input_image = xp.ones((256, 256)) * 0.1 output_image = noise.resolve(input_image) - self.assertIsInstance(output_image, np.ndarray) + + self.assertIsInstance(output_image, self.array_type) self.assertEqual(output_image.shape, (256, 256)) +# Extending the test and setting the backend to torch +@unittest.skipUnless(TORCH_AVAILABLE, "PyTorch is not installed.") +class TestNoises_PyTorch(TestNoises_NumPy): + BACKEND = "torch" + pass + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()