diff --git a/deeptrack/math.py b/deeptrack/math.py index 13af56ce..ff81fe6e 100644 --- a/deeptrack/math.py +++ b/deeptrack/math.py @@ -1069,8 +1069,7 @@ def __init__( super().__init__(ndimage.median_filter, size=ksize, **kwargs) -#TODO ***AL*** revise Pool - torch, typing, docstring, unit test -class Pool(Feature): +class Pool(Feature): # Deprecated, children will be independent in the future. """Downsamples the image by applying a function to local regions of the image. @@ -1078,15 +1077,15 @@ class Pool(Feature): non-overlapping blocks of size `ksize` and applying the specified pooling function to each block. The result is a downsampled image where each pixel value represents the result of the pooling function applied to the - corresponding block. + corresponding block. This pooling only works with numpy functions. Parameters ---------- - pooling_function: function + pooling_function: Numpy function A function that is applied to each local region of the image. DOES NOT NEED TO BE WRAPPED IN ANOTHER FUNCTION. - The `pooling_function` must accept the input image as a keyword argument - named `input`, as it is called via `utils.safe_call`. + The `pooling_function` must accept the input image as a keyword + argument named `input`, as it is called via `utils.safe_call`. Examples include `np.mean`, `np.max`, `np.min`, etc. ksize: int Size of the pooling kernel. @@ -1095,7 +1094,8 @@ class Pool(Feature): Methods ------- - `get(image: np.ndarray | Image, ksize: int, **kwargs: Any) --> np.ndarray` + `get(image: NDArray, + ksize: int, **kwargs: Any) --> NDArray` Applies the pooling function to the input image. Examples @@ -1152,17 +1152,17 @@ def __init__( def get( self: Pool, - image: np.ndarray | Image, + image: NDArray, ksize: int, **kwargs: Any, - ) -> np.ndarray: + ) -> NDArray: """Applies the pooling function to the input image. - This method applies the pooling function to the input image. + This method applies `pooling_function` to the input image. Parameters ---------- - image: np.ndarray + image: NDArray | torch.Tensor The input image to pool. ksize: int Size of the pooling kernel. @@ -1171,7 +1171,7 @@ def get( Returns ------- - np.ndarray + NDArray | torch.Tensor The pooled image. """ @@ -1188,15 +1188,20 @@ def get( ) -#TODO ***AL*** revise AveragePooling - torch, typing, docstring, unit test class AveragePooling(Pool): """Apply average pooling to an image. - This class reduces the resolution of an image by dividing it into - non-overlapping blocks of size `ksize` and applying the average function to - each block. The result is a downsampled image where each pixel value - represents the average value within the corresponding block of the - original image. + This class inherits from `Pool` to reduce the resolution of an image by + dividing it into non-overlapping blocks of size `ksize` and applying the + `max` function to each block. The result is a downsampled image where each + pixel value represents the maximum value within the corresponding block of + the original image. This is useful for reducing the size of an image while + retaining the most significant features. + + If the backend is numpy, the downsampling is performed using + `skimage.measure.block_reduce`. + If the backend is torch, the downsampling + is performed using `torch.nn.functional.avg_pool2d`. Parameters ---------- @@ -1221,8 +1226,9 @@ class AveragePooling(Pool): Notes ----- - Calling this feature returns a `np.ndarray` by default. If - `store_properties` is set to `True`, the returned array will be + Calling this feature returns a pooled image of the input, it will return + either numpy or torch depending on the backend. If `store_properties` is + set to `True` and the input is a numpy array, the returned array will be automatically wrapped in an `Image` object. This behavior is handled internally and does not affect the return type of the `get()` method. @@ -1235,7 +1241,9 @@ def __init__( ): """Initialize the parameters for average pooling. - This constructor initializes the parameters for average pooling. + This constructor initializes the parameters for average pooling and + checks whether to use the numpy or torch implementation, defaults to + numpy. Parameters ---------- @@ -1248,6 +1256,110 @@ def __init__( super().__init__(np.mean, ksize=ksize, **kwargs) + def _get_numpy( + self, + image: NDArray, + ksize: int = 3, + **kwargs, + ): + """Method to perform average pooling with the numpy backend enabled. + + Returns the result of the image passed to the scikit image block_reduce + function with `np.mean()` as the pooling function. + + Parameters + ---------- + image: NDArray + Input image to be pooled. + ksize: int + Kernel size of the pooling operation. + + Returns + ------- + NDArray + The pooled image as a `NDArray`. + + """ + return utils.safe_call( + skimage.measure.block_reduce, + image=image, + func=self.pooling, # This will be np.mean for this class. + block_size=ksize, + **kwargs, + ) + + def _get_torch( + self, + image: torch.Tensor, + ksize: int=3, + **kwargs, + ): + """Method to perform average pooling with the torch backend enabled. + + Returns the result of the image passed to a torch average + pooling layer. + + Parameters + ---------- + image: torch.Tensor + Input image to be pooled. + ksize: int + Kernel size of the pooling operation. + + Returns + ------- + torch.Tensor + The pooled image as a `torch.Tensor`. + + """ + + # If needed, expand tensor shape + if len(image.shape) == 2: + expanded_image = image.unsqueeze(0) + + pooled_image = torch.nn.functional.avg_pool2d( + expanded_image, kernel_size=ksize, + ) + # Remove the expanded dim. + return pooled_image.squeeze(0) + + return torch.nn.functional.avg_pool2d( + image, + kernel_size=ksize, + ) + + def get( + self, + image: NDArray | torch.Tensor, + ksize: int=3, + **kwargs, + ): + """Method to perform pooling with either torch or numpy backend. + + Checks the current backend and chooses the appropriate function to pool + the input image, either `_get_torch` or `_get_numpy`. + + Parameters + ---------- + image: NDArray | torch.Tensor + Input image to be pooled. + ksize: int + Kernel size of the pooling operation. + + Returns + ------- + NDArray | torch.Tensor + The pooled image as `NDArray` or `torch.Tensor` depending on + the backend. + + """ + if self.get_backend() == "numpy": + return self._get_numpy(image, ksize, **kwargs,) + elif self.get_backend() == "torch": + return self._get_torch(image, ksize, **kwargs,) + else: + raise NotImplementedError(f"Backend {self.backend} not supported") + #TODO ***AL*** revise MaxPooling - torch, typing, docstring, unit test class MaxPooling(Pool): diff --git a/deeptrack/tests/test_math.py b/deeptrack/tests/test_math.py index 197afb40..9baf3962 100644 --- a/deeptrack/tests/test_math.py +++ b/deeptrack/tests/test_math.py @@ -82,12 +82,28 @@ def test_Blur(self): #blurred_image = feature.resolve(input_image) #self.assertTrue(xp.all(blurred_image == expected_output)) + def test_AveragePooling(self): + input_image = np.array([[1, 2, 3, 4], [5, 6, 7, 8]], dtype=float) + feature = math.AveragePooling(ksize=2) + pooled_image = feature.resolve(input_image) + self.assertTrue(np.all(pooled_image == [[3.5, 5.5]])) + # Extending the test and setting the backend to torch @unittest.skipUnless(TORCH_AVAILABLE, "PyTorch is not installed.") class TestMath_Torch(TestMath_Numpy): BACKEND = "torch" - pass + + def test_AveragePooling(self): + input_image = torch.tensor([[[ [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0] ]]]) + feature = math.AveragePooling(ksize=2) + pooled_image = feature(input_image, ksize=2) + expected = torch.tensor([[[[3.5, 5.5]]]]) + self.assertEqual(pooled_image.shape, expected.shape) + self.assertTrue(torch.allclose(pooled_image, expected)) + + class TestMath(unittest.TestCase): @@ -109,6 +125,7 @@ def test_AveragePooling(self): pooled_image = feature.resolve(input_image) self.assertTrue(np.all(pooled_image == [[3.5, 5.5]])) + def test_MaxPooling(self): input_image = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) feature = math.MaxPooling(ksize=2)