8000 Speed up skimage.feature.peak_local_max by clementkng · Pull Request #3984 · scikit-image/scikit-image · GitHub
[go: up one dir, main page]

Skip to content

Speed up skimage.feature.peak_local_max #3984

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jul 27, 2019

Conversation

clementkng
Copy link
Contributor
@clementkng clementkng commented Jun 30, 2019

Description

@brownmk's proposal to modify the peak_local_max algorithm to recursively pass a small image enclosing each object rather than an image of the same size, improving algorithm performance.

Fixes gh-3974

Checklist

For reviewers

  • Check that the PR title is short, concise, and will make sense 1 year
    later.
  • Check that new functions are imported in corresponding __init__.py.
  • Check that new features, API changes, and deprecations are mentioned in
    doc/release/release_dev.rst.
  • Consider backporting the PR with @meeseeksdev backport to v0.14.x

@sciunto
Copy link
Member
sciunto commented Jul 1, 2019

@clementkng Thanks for submitting. Is it still a WIP? I see that all the tests are passing...

@clementkng
Copy link
Contributor Author

@sciunto Hi, I believe it may still be a WIP b/c the algorithm has changed a bit since to pass the tests, so I'm not sure if the performance gain is still there. Also, I'm considering putting in new tests that @brownmk used initially to test the code. Or do you think there's enough coverage w/ the existing tests?

@brownmk
Copy link
brownmk commented Jul 1, 2019

Hi, The performance gain is still there. If you want to add my test case, use the method below:

import numpy as np
from scipy import ndimage as ndi
from peak import peak_local_max

def test_many_objects():
    mask=np.zeros([500,500], dtype=bool)
    x,y=np.indices((500,500))
    x_c=x//20*20+10
    y_c=y//20*20+10
    mask[(x-x_c)**2+(y-y_c)**2<8**2]=True
    # create a mask, label each disk, create distance image for peak searching
    labels, num_objs=ndi.label(mask)
    dist=ndi.distance_transform_edt(mask)
    local_max=peak_local_max(dist, min_distance=20, indices=True, exclude_border=False, labels=labels)
    assert(len(local_max) == 625)

Copy link
Member
@sciunto sciunto left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left several comments (sorry if I raised some on which you already wanted to work on before reviews). They are all minor and intended to improve the code readability.

else:
return out

threshold_abs = threshold_abs if threshold_abs is not None else image.min()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: I would move the block "if type(exclude_border)..." few lines above just before or after this line, to gather these lines which have the same spirit.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only this one to fix, and it will be good for me.


for i, obj in enumerate(ndi.find_objects(labels)):
label = i + 1
# print("Label>", label)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line can be removed.


def _exclude_border(mask, footprint, exclude_border):
"""
Remove peaks round the borders
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Remove peaks round the borders
Remove peaks round the borders.

def _get_peak_mask(image, min_distance, footprint, threshold_abs,
threshold_rel):
"""
Return the mask containing all peak candidates above thresholds
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Return the mask containing all peak candidates above thresholds
Return the mask containing all peak candidates above thresholds.

footprint, exclude_border)

for i, obj in enumerate(ndi.find_objects(labels)):
label = i + 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable seems to be unused... (Am i correct?)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable label is used in the next line:

img=image[obj]*(labels[obj]==label)

return out

threshold_abs = threshold_abs if threshold_abs is not None else image.min()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment just below " # In the case of labels, recursively build and return an output
# operating on each label separately" seems to be uncorrect to me now. There is no more recursive call and instead, it's a call to ndi on each label. Can you fix this please?

inner_mask = _exclude_border(np.ones_like(labels, dtype=bool),
footprint, exclude_border)

for i, obj in enumerate(ndi.find_objects(labels)):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a comment describing what this for loop is doing would help the reader.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brownmk How would you describe this loop?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each label, extract a smaller image enclosing the object of interest, identify num_peaks_per_label peaks and mark them in variable out.

for i, obj in enumerate(ndi.find_objects(labels)):
label = i + 1
# print("Label>", label)
img = image[obj] * (labels[obj] == label)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a less generic variable name here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brownmk What would you recommend here?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call it image_object? Up to you.

@pep8speaks
Copy link
pep8speaks commented Jul 1, 2019

Hello @clementkng! Thanks for updating this PR. We checked the lines you've touched for PEP 8 issues, and found:

There are currently no PEP 8 issues detected in this Pull Request. Cheers! 🍻

Comment last updated at 2019-07-25 02:51:42 UTC

@sciunto
Copy link
Member
sciunto commented Jul 2, 2019

The test failure is due to #3985 Not blocking for this PR.

@jni @hmaarrfk would you like to have a look as well?

mask = mask.swapaxes(0, i)
remove = (footprint.shape[i] if footprint is not None
else 2 * exclude_border)
mask[:remove // 2] = mask[-remove // 2:] = False
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really dislike swapping axes

mask[(slice(None),) * i + slice(None, remove // 2)] = False
mask[(slice(None),) * i + slice(-remove // 2, None)] = False

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, are you referring to the line above or below or both?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace

        mask = mask.swapaxes(0, i)
        remove = (footprint.shape[i] if footprint is not None
                  else 2 * exclude_border)
        mask[:remove // 2] = mask[-remove // 2:] = False
        mask = mask.swapaxes(0, i)

with what @hmaarrfk provided:

        remove = (footprint.shape[i] if footprint is not None
                  else 2 * exclude_border)
        mask[(slice(None),) * i + slice(None, remove // 2)] = False
        mask[(slice(None),) * i + slice(-remove // 2, None)] = False

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10000

The code actually should be:

def _exclude_border(mask, footprint, exclude_border):
    """
    Remove peaks round the borders
    """
    # zero out the image borders
    for i in range(mask.ndim):
        remove = (footprint.shape[i] if footprint is not None
                  else 2 * exclude_border)
        mask[(slice(None),) * i + (slice(None, remove // 2),)] = False
        mask[(slice(None),) * i + (slice(-remove // 2, None),)] = False
    return mask

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for making that super explicit

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment should be ("round" was a typo):

Remove peaks near the borders

out = np.zeros_like(image, dtype=np.bool)

# no peak for a trivial image
if np.all(image == image.flat[0]):
Copy link
Member
@hmaarrfk hmaarrfk Jul 3, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trivial images are definitely an "edge case", is this check really necessary? the call to np.all and checking equality for large images is actually quite expensive. Since this is an "unlikely" scenerio, is it better to move this logic to the end or remove it all together.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the code to the end does not help, as it will still need to be checked before final results are returned (potentially overwrite peak candidates).
I am okay with removing the logic, just we might need to somehow documente that there could be an incompatibility for edge cases.

Here is an example, where there was no peak before, it will become 25 peaks. I agree this is a case probably users should check, instead of checking it every time within the library.

        image = np.ones((5, 5))
        # return 0, will return 25 if we remove the trivial case check
        print(len(peak.peak_local_max(image, min_distance=0, threshold_abs=0, indices=True)))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, thanks for the explanation. I guess this can be left as a secondary issue.


coordinates = _get_high_intensity_peaks(image, out, num_peaks)
if indices is True:
return coordinates
else:
nd_indices = tuple(coordinates.T)
out[nd_indices] = True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coordinates is obtained from out. Do you need to set out again?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. If num_peaks is provided, out may contain more than num_peaks candidates. It needs to be regenerated in that case.

I actually see a bug here. It was missing a statement out.fill(False).
The code review really helps!!

It should be:

        if not indices and np.isinf(num_peaks):
            return out
        coordinates = _get_high_intensity_peaks(image, out, num_peaks)
        if indices:
            return coordinates
        else:
            out.fill(False)
            nd_indices = tuple(coordinates.T)
            out[nd_indices] = True
            return out

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where did the first two lines of code come from?

Copy link
@brownmk brownmk Jul 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first two lines is to deal with the case where user does not provide num_peaks (defaults to all, np.inf) and indices is False, out is already the answer that can be returned.
The code I provided (bold) in within the context below:


        for i,obj in enumerate(ndi.find_objects(labels)):
            label=i+1
            #print("Label>", label)
            img=image[obj]*(labels[obj]==label)
            mask=_get_peak_mask(img, min_distance, footprint, threshold_abs, threshold_rel)
            if exclude_border:
                # remove peaks fall in the exclude region
                mask &= inner_mask[obj]
            coordinates =_get_high_intensity_peaks(img, mask, num_peaks_per_label)
            nd_indices = tuple(coordinates.T)
            mask.fill(False)
            mask[nd_indices] = True
            out[obj] += mask

        if not indices and np.isinf(num_peaks):
            return out
        coordinates = _get_high_intensity_peaks(image, out, num_peaks)
        if indices:
            return coordinates
        else:
            out.fill(False)
            nd_indices = tuple(coordinates.T)
            out[nd_indices] = True
            return out

    # Non maximum filter
    mask = _get_peak_mask(image, min_distance, footprint, threshold_abs, threshold_rel)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brownmk are you sure you don't want to open your own PR and take it over from here? You seem to be going through this quite extensively. Github Desktop, and editors like Atom or VS Code make git much less painful.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's less of git, it's the 500-line guideline seems rather intimidating. All issues are addressed now, I will try to learn it next time.

https://github.com/scikit-image/scikit-image/blob/master/CONTRIBUTING.txt

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a really fair point. I'll open an issue about this.

Most of that is a reference for us to point people to when they hit problems. We often forget how we setup our own environments....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brownmk If you want, I can try guiding you through a setup process in exchange for allowing me to create a PR w/ your code.

@hmaarrfk
Copy link
Member
hmaarrfk commented Jul 3, 2019

Anyway to add teh benchmark code to the benchmarks dir?

@clementkng
Copy link
Contributor Author

@hmaarrfk I can add the benchmark code (assuming you mean the code sample under Way to Produce in the original issue). I can extrapolate based off of other benchmarks how the benchmark for peak_local_max should be written, but since I would need conda to run asv, it would require some significant overhead on my end to get that set up. Would it be faster to push my benchmark code and have someone else confirm that it works, or should I proceed w/ a local install?

@hmaarrfk
Copy link
Member
hmaarrfk commented Jul 4, 2019

i don't think you need conda for asv do you?

@clementkng
Copy link
Contributor Author

According to the install link, I need either virtualenv or conda. For now, the virtualenv solution is not working for me, possibly b/c I'm working of the Windows Subsystem for Linux.

@hmaarrfk
Copy link
Member
hmaarrfk commented Jul 4, 2019

Windows Subsystem for Linux

Having never used WSL it seems like a potential challenge. I feel like you are always going to hit these problems and never know if your code is wrong, or if it is a bug in WSL.

@hmaarrfk
Copy link
Member
hmaarrfk commented Jul 4, 2019
skdev) mark2@xps ~/g/scikit-image speed-up-peak-local-max↑·1|+2                                                                                   
$ asv continuous -b PeakLocalMaxSuite -E conda:3.7 master speed-up-peak-local-max                                                                  
· Creating environments
· Discovering benchmarks
· Running 2 total benchmarks (2 commits * 1 environments * 1 benchmarks)
[  0.00%] · For scikit-image commit 7cd12184 <master> (round 1/2):
[  0.00%] ·· Building for conda-py3.7-cython-numpy-scipy.
[  0.00%] ·· Benchmarking conda-py3.7-cython-numpy-scipy
[ 25.00%] ··· Running (benchmark_peak_local_max.PeakLocalMaxSuite.time_peak_local_max--).
[ 25.00%] · For scikit-image commit 5a06093b <speed-up-peak-local-max> (round 1/2):
[ 25.00%] ·· Building for conda-py3.7-cython-numpy-scipy.
[ 25.00%] ·· Benchmarking conda-py3.7-cython-numpy-scipy
[ 50.00%] ··· Running (benchmark_peak_local_max.PeakLocalMaxSuite.time_peak_local_max--).
[ 50.00%] · For scikit-image commit 5a06093b <speed-up-peak-local-max> (round 2/2):
[ 50.00%] ·· Benchmarking conda-py3.7-cython-numpy-scipy
[ 75.00%] ··· benchmark_peak_local_max.PeakLocalMaxSuite.time_peak_local_max                                                             40.2±0.9ms
[ 75.00%] · For scikit-image commit 7cd12184 <master> (round 2/2):
[ 75.00%] ·· Building for conda-py3.7-cython-numpy-scipy.
[ 75.00%] ·· Benchmarking conda-py3.7-cython-numpy-scipy
[100.00%] ··· benchmark_peak_local_max.PeakLocalMaxSuite.time_peak_local_max                                                                1.75±0s
       before           after         ratio
     [7cd12184]       [5a06093b]
     <master>         <speed-up-peak-local-max>
-         1.75±0s       40.2±0.9ms     0.02  benchmark_peak_local_max.PeakLocalMaxSuite.time_peak_local_max

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
PERFORMANCE INCREASED.

@hmaarrfk
Copy link
Member
hmaarrfk commented Jul 4, 2019

Seems to work too

asv continuous -b PeakLocalMaxSuite -E virtualenv:3.7 master speed-up-peak-local-max

@hmaarrfk
Copy link
Member
hmaarrfk commented Jul 4, 2019

I think the test for the trivial case should be added back. Unless we decide that this is a behavioural change we want to make. Which I don't think this PR is advocating for.

You can test for it in a different way, that isn't O(N) in cost.

$ git diff skimage/                                                                                                                                
diff --git a/skimage/feature/peak.py b/skimage/feature/peak.py
index 7736bf70c..7aa6ae20a 100644
--- a/skimage/feature/peak.py
+++ b/skimage/feature/peak.py
@@ -190,6 +190,14 @@ def peak_local_max(image, min_distance=1, threshold_abs=None,
             out[obj] += mask
 
         coordinates = _get_high_intensity_peaks(image, out, num_peaks)
+
+        # Test for the case where the image is a constant
+        # Here, we decide to return to the user that there is no local maximum
+        if coordinates.shape[0] == image.size:
+            if indices is True:
+                return np.empty((0, 2), np.int)
+            else:
+                return np.zeros_like(image, dtype=bool)
         if indices is True:
             return coordinates
         else:
@@ -205,6 +213,14 @@ def peak_local_max(image, min_distance=1, threshold_abs=None,
     # Select highest intensities (num_peaks)
     coordinates = _get_high_intensity_peaks(image, mask, num_peaks)
 
+    # Test for the case where the image is a constant
+    # Here, we decide to return to the user that there is no local maximum
+    if coordinates.shape[0] == image.size:
+        if indices is True:
+            return np.empty((0, 2), np.int)
+        else:
+            return np.zeros_like(image, dtype=bool)
+
     if indices is True:
         return coordinates
     else:

A test like the

In [1]: from skimage.feature.peak import peak_local_max                                                                                            
i
In [2]: import numpy as np                                                                                                                         

In [3]: image = np.ones((5, 5))                                                                                                                    

In [4]: print(len(peak_local_max(image, min_distance=0, threshold_abs=0, indices=True)))                                                           
0

Should also be added to test suite so that this behaviour doesn't magically disappear

@brownmk
Copy link
brownmk commented Jul 4, 2019

The change is not right. One cannot determine if the image is trivial by relying on the number of peaks detected. The returned number of peaks depends on the settings of threshold and num_peaks. For instance one can set num_peaks to 1 for a trivial image. We should stick with the original code here.

I think the test for the trivial case should be added back. Unless we decide that this is a behavioural change we want to make. Which I don't think this PR is advocating for.

You can test for it in a different way, that isn't O(N) in cost.

@hmaarrfk
Copy link
Member
hmaarrfk commented Jul 4, 2019

Ah got it. Sorry for misunderstanding that. Yeah, for this PR. the original check should be kept.

@clementkng
Copy link
Contributor Author

@hmaarrfk Is there any update in the background on this PR or the issue about the trivial check?

@hmaarrfk
Copy link
Member

background? I think you had the PR mostly complete. I don't think the failures are due to your code.

dist = ndi.distance_transform_edt(mask)
local_max = peak.peak_local_max(dist, min_distance=20, indices=True,
exclude_border=False, labels=labels)
assert (len(local_max) == 625)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert (len(local_max) == 625)
assert len(local_max) == 625

just triggering a build

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just apply this to trigger a fresh new build. I think builds are fixed.

Copy link
Member
@hmaarrfk hmaarrfk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builds should be passing these days.

@hmaarrfk
Copy link
Member

I think you can likely remove your WIP too.

@clementkng clementkng changed the title [WIP] Speed up skimage.feature.peak_local_max Speed up skimage.feature.peak_local_max Jul 25, 2019
@sciunto
Copy link
Member
sciunto commented Jul 27, 2019

Thanks @clementkng That's a great contribution!

@sciunto sciunto merged commit 7ec25d9 into scikit-image:master Jul 27, 2019
ttung pushed a commit to ttung/scikit-image that referenced this pull request Oct 21, 2019
scikit-image#3984 adds an optimization where a flat image immediately returns.  However, it returns an array where the shape is incorrectly set to always be 2D when indices=True.  A test is added to catch this scenario, and a fix is introduced where we set the output array's shape based on the input array's shape.
hmaarrfk pushed a commit that referenced this pull request Oct 21, 2019
* Fix peak finding for empty images when indices=True

#3984 adds an optimization where a flat image immediately returns.  However, it returns an array where the shape is incorrectly set to always be 2D when indices=True.  A test is added to catch this scenario, and a fix is introduced where we set the output array's shape based on the input array's shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Proposal to speed up skimage.feature.peak_local_max
5 participants
0