Repository: scikit-image/scikit-image

Issue:
Sato filter memory usage with multiple sigmas
## Description

Sato filter and other ridge filters in _ridges.py_ have quite large unnecessary memory usage if called with multiple sigma values. It is basically allocating for all results, storing results and then taking maximum, while it is possible to just take max after each sigma.

Original code:
```python
    # Generate empty (n+1)D arrays for storing auxiliary images filtered
    # at different (sigma) scales
    filtered_array = np.zeros(sigmas.shape + image.shape, dtype=float_dtype)

    # Filtering for all (sigma) scales
    for i, sigma in enumerate(sigmas):

        # Calculate (sorted) eigenvalues
        lamba1, *lambdas = compute_hessian_eigenvalues(image, sigma,
                                                       sorting='val',
                                                       mode=mode, cval=cval)

        # Compute tubeness, see  equation (9) in reference [1]_.
        # np.abs(lambda2) in 2D, np.sqrt(np.abs(lambda2 * lambda3)) in 3D
        filtered = np.abs(np.multiply.reduce(lambdas)) ** (1/len(lambdas))

        # Remove background and store results in (n+1)D matrices
        filtered_array[i] = np.where(lambdas[-1] > 0, filtered, 0)

    # Return for every pixel the maximum value over all (sigma) scales
    return np.max(filtered_array, axis=0)
```

Modified code:
```python
    # Generate empty (n+1)D arrays for storing auxiliary images filtered
    # at different (sigma) scales
    filtered_array = np.zeros(image.shape, dtype=float_dtype)

    # Filtering for all (sigma) scales
    for i, sigma in enumerate(sigmas):

        # Calculate (sorted) eigenvalues
        lamba1, *lambdas = compute_hessian_eigenvalues(image, sigma,
                                                       sorting='val',
                                                       mode=mode, cval=cval)

        # Compute tubeness, see  equation (9) in reference [1]_.
        # np.abs(lambda2) in 2D, np.sqrt(np.abs(lambda2 * lambda3)) in 3D
        filtered = np.abs(np.multiply.reduce(lambdas)) ** (1/len(lambdas))

        # Remove background and store results in (n+1)D matrices
        filtered = np.where(lambdas[-1] > 0, filtered, 0)
        filtered_array = np.maximum(filtered_array,filtered)

    # Return for every pixel the maximum value over all (sigma) scales
    return filtered_array
```


## Way to reproduce
Call sato filter with huge image and multiple sigmas and observe memory use.


## Version information
`
3.10.4 | packaged by conda-forge | (main, Mar 30 2022, 08:38:02) [MSC v.1916 64 bit (AMD64)]
Windows-10-10.0.19043-SP0
scikit-image version: 0.19.2
numpy version: 1.21.5
`



Discussion:
@tkumpumaki, thanks for the report. It looks like you already figured out a solution? Would you be up for a pull request with your suggested performance improvement? :slightly_smiling_face: In that case have a look have a look at our [contribution guide](https://scikit-image.org/docs/stable/contribute.html) and feel free to ask and ping me!
Sure, have to take a look on other filters if solution works in them too so that implementations would be consistent and do some tests.
@tkumpumaki don't hesitate to submit a (small) PR for a single function. "Many small PRs get in faster than a single larger one." (cf. https://github.com/scikit-image/scikit-image/issues/1185#issuecomment-522335391)

Resolving patch:
diff --git a/benchmarks/benchmark_filters.py b/benchmarks/benchmark_filters.py
--- a/benchmarks/benchmark_filters.py
+++ b/benchmarks/benchmark_filters.py
@@ -2,7 +2,7 @@
 # https://asv.readthedocs.io/en/latest/writing_benchmarks.html
 import numpy as np
 
-from skimage import data, filters
+from skimage import data, filters, color
 from skimage.filters.thresholding import threshold_li
 
 
@@ -103,3 +103,50 @@ def time_integer_image(self):
 
     def time_float32_image(self):
         result1 = threshold_li(self.image_float32)
+
+
+class RidgeFilters:
+    """Benchmark ridge filters in scikit-image."""
+
+    def setup(self):
+        # Ensure memory footprint of lazy import is included in reference
+        self._ = filters.meijering, filters.sato, filters.frangi, filters.hessian
+        self.image = color.rgb2gray(data.retina())
+
+    def peakmem_setup(self):
+        """peakmem includes the memory used by setup.
+        Peakmem benchmarks measure the maximum amount of RAM used by a
+        function. However, this maximum also includes the memory used
+        by ``setup`` (as of asv 0.2.1; see [1]_)
+        Measuring an empty peakmem function might allow us to disambiguate
+        between the memory used by setup and the memory used by slic (see
+        ``peakmem_slic_basic``, below).
+        References
+        ----------
+        .. [1]: https://asv.readthedocs.io/en/stable/writing_benchmarks.html#peak-memory
+        """
+        pass
+
+    def time_meijering(self):
+        filters.meijering(self.image)
+
+    def peakmem_meijering(self):
+        filters.meijering(self.image)
+
+    def time_sato(self):
+        filters.sato(self.image)
+
+    def peakmem_sato(self):
+        filters.sato(self.image)
+
+    def time_frangi(self):
+        filters.frangi(self.image)
+
+    def peakmem_frangi(self):
+        filters.frangi(self.image)
+
+    def time_hessian(self):
+        filters.hessian(self.image)
+
+    def peakmem_hessian(self):
+        filters.hessian(self.image)
diff --git a/skimage/filters/ridges.py b/skimage/filters/ridges.py
--- a/skimage/filters/ridges.py
+++ b/skimage/filters/ridges.py
@@ -149,7 +149,9 @@ def meijering(image, sigmas=range(1, 10, 2), alpha=None,
     mtx = linalg.circulant(
         [1, *[alpha] * (image.ndim - 1)]).astype(image.dtype)
 
-    filtered = []
+    # Generate empty array for storing maximum value
+    # from different (sigma) scales
+    filtered_max = np.zeros_like(image)
     for sigma in sigmas:  # Filter for all sigmas.
         eigvals = hessian_matrix_eigvals(hessian_matrix(
             image, sigma, mode=mode, cval=cval, use_gaussian_derivatives=True))
@@ -164,8 +166,9 @@ def meijering(image, sigmas=range(1, 10, 2), alpha=None,
         max_val = vals.max()
         if max_val > 0:
             vals /= max_val
-        filtered.append(vals)
-    return np.max(filtered, axis=0)  # Return pixel-wise max over all sigmas.
+        filtered_max = np.maximum(filtered_max, vals)
+
+    return filtered_max  # Return pixel-wise max over all sigmas.
 
 
 def sato(image, sigmas=range(1, 10, 2), black_ridges=True,
@@ -221,7 +224,9 @@ def sato(image, sigmas=range(1, 10, 2), black_ridges=True,
     if not black_ridges:  # Normalize to black ridges.
         image = -image
 
-    filtered = []
+    # Generate empty array for storing maximum value
+    # from different (sigma) scales
+    filtered_max = np.zeros_like(image)
     for sigma in sigmas:  # Filter for all sigmas.
         eigvals = hessian_matrix_eigvals(hessian_matrix(
             image, sigma, mode=mode, cval=cval, use_gaussian_derivatives=True))
@@ -232,8 +237,8 @@ def sato(image, sigmas=range(1, 10, 2), black_ridges=True,
         eigvals = eigvals[:-1]
         vals = (sigma ** 2
                 * np.prod(np.maximum(eigvals, 0), 0) ** (1 / len(eigvals)))
-        filtered.append(vals)
-    return np.max(filtered, axis=0)  # Return pixel-wise max over all sigmas.
+        filtered_max = np.maximum(filtered_max, vals)
+    return filtered_max  # Return pixel-wise max over all sigmas.
 
 
 def frangi(image, sigmas=range(1, 10, 2), scale_range=None,
@@ -318,7 +323,9 @@ def frangi(image, sigmas=range(1, 10, 2), scale_range=None,
     if not black_ridges:  # Normalize to black ridges.
         image = -image
 
-    filtered = []
+    # Generate empty array for storing maximum value
+    # from different (sigma) scales
+    filtered_max = np.zeros_like(image)
     for sigma in sigmas:  # Filter for all sigmas.
         eigvals = hessian_matrix_eigvals(hessian_matrix(
             image, sigma, mode=mode, cval=cval, use_gaussian_derivatives=True))
@@ -345,8 +352,8 @@ def frangi(image, sigmas=range(1, 10, 2), scale_range=None,
         vals = 1.0 - np.exp(-r_a**2 / (2 * alpha**2))  # plate sensitivity
         vals *= np.exp(-r_b**2 / (2 * beta**2))  # blobness
         vals *= 1.0 - np.exp(-s**2 / (2 * gamma**2))  # structuredness
-        filtered.append(vals)
-    return np.max(filtered, axis=0)  # Return pixel-wise max over all sigmas.
+        filtered_max = np.maximum(filtered_max, vals)
+    return filtered_max  # Return pixel-wise max over all sigmas.
 
 
 def hessian(image, sigmas=range(1, 10, 2), scale_range=None, scale_step=None,


Test patch: