Repository: scikit-image/scikit-image

Issue:
`peak_local_max` excludes peaks at border despite `exclude_border=0`
## Description

## Way to reproduce

The python demo below needs a 2D array contained in `data.npy` which is stored in [data.zip](https://github.com/scikit-image/scikit-image/files/8804871/data.zip)

Within that script I run the `peak_local_max` command three times. The respective settings and the resulting peaks are indicated in these plots:  
![demo](https://user-images.githubusercontent.com/12622691/171169144-6a2aab7f-7ec1-4507-92fc-d1636552e862.png)  
(The underlying data was actually produced by tiling a smaller array three times along the vertical axis.)

From my perspective there is a sixth peak missing in the first plot. I do get that peak if I reduce `min_distance` to 30:  
![expected](https://user-images.githubusercontent.com/12622691/171170957-ea6db148-316e-4172-98e3-a2908ce1637f.png)  
but I don't understand why it is removed with `min_distance=63`. The six peaks are clearly separated by more than 100 units.

```python
import numpy as np 
from skimage.feature.peak import peak_local_max 
import matplotlib.pyplot as plt

y = np.load("data.npy")

fig, axs = plt.subplots( nrows=3 )

for i, ax in enumerate(axs):

    pcolor = ax.pcolormesh( y.T )
    cbar = plt.colorbar( pcolor, ax=ax )
    
    if i < 2:
        ax.tick_params( axis='x', which='both', bottom=False, top=False, labelbottom=False)
    
    if i==0:
        peaks = peak_local_max( y, min_distance=63, threshold_abs=-0.039, exclude_border=0 )
        ax.set_title('min_distance = 63, exclude_border = 0')
    elif i==1:
        peaks = peak_local_max( y, min_distance=63, threshold_abs=-0.039, exclude_border=True )
        ax.set_title('min_distance = exclude_border = 63')
    elif i==2:
        peaks = peak_local_max( y, min_distance=1, threshold_abs=-0.039, exclude_border=True )
        ax.set_title('min_distance = exclude_border = 1')
    else:
        raise NotImplementedError
    
    axs[i].plot( peaks[:,0], peaks[:,1], marker='x', linewidth=0, color='w' )

fig.show()
```


## Version information

```
3.10.4 | packaged by conda-forge | (main, Mar 24 2022, 17:38:57) [GCC 10.3.0]
Linux-5.13.0-44-generic-x86_64-with-glibc2.31
scikit-image version: 0.19.2
numpy version: 1.21.6
```





Discussion:
Hey @baloe, thank you for the detailed report and sorry for the delayed answer. From what I can gather the following is happening:

As you are not specifying the `footprint` parameter it defaults to https://github.com/scikit-image/scikit-image/blob/1ede87167068c9e8fc8871baf6a80fcce20eabe9/skimage/feature/peak.py#L254-L255
which is passed to [scipy.ndimage.maximum_filter](https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.maximum_filter.html) in https://github.com/scikit-image/scikit-image/blob/1ede87167068c9e8fc8871baf6a80fcce20eabe9/skimage/feature/peak.py#L41-L42

During debugging I plotted `image_max` for both cases: first for `min_distance=63` and second for `min_distance=30` (the orientation is different from your plots) :
![dist63](https://user-images.githubusercontent.com/20140352/172060263-7e6e70cd-6e8b-458b-a249-4da47d5cb2cd.svg) 
![dist30](https://user-images.githubusercontent.com/20140352/172060275-4cfbb82e-637e-466f-8f58-b40eb563f509.svg)

The missing peak for `min_distance=63` would be located at `[309, 720]` which falls into the yellow area that is blotted out by the constant border value `mode=constant, cval=0`. This only happens if your actual peak is smaller than `cval`.  As this happens even before `exclude_borders` would have an impact and is dependent on the actual image values, I'm assuming this to be a bug! 

A fix might be using `mode=mirror` or `cval=image.min()` so that the border has no impact and we get
![dist63](https://user-images.githubusercontent.com/20140352/172061018-3e18d426-f497-42a9-9459-234ca4de6c99.svg)

@baloe would you be interested in creating a pull request to apply this fix? I'm happy to help you with that. :slightly_smiling_face: 



Modifying `mode` is, I think, the best approach to avoid searching for image minimum value.
Hey, @baloe I saw here that you created https://github.com/baloe/scikit-image/commit/fa56cbf1e475a92ad1172481f5a610a9fce16d33. If that's in anticipation of a PR you might want to checkout our [contribution guide](https://scikit-image.org/docs/stable/contribute.html) if you haven't already. That might help you with common hurdles. E.g. I can see that you added the commit on the `main` branch of your fork but it is usally a little easier and recommended to use your create your own feature branch for the PR. Feel free to ping me if you have any questions. 
Hi @lagru, I am using https://github.com/baloe/scikit-image/commit/fa56cbf1e475a92ad1172481f5a610a9fce16d33 for now, but I would also like to provide a PR. I will have a look into the contribution guide. I don't have much time these days though, so it may take two weeks.

Resolving patch:
diff --git a/skimage/feature/peak.py b/skimage/feature/peak.py
--- a/skimage/feature/peak.py
+++ b/skimage/feature/peak.py
@@ -40,7 +40,7 @@ def _get_peak_mask(image, footprint, threshold, mask=None):
         return image > threshold
 
     image_max = ndi.maximum_filter(image, footprint=footprint,
-                                   mode='constant')
+                                   mode='nearest')
 
     out = image == image_max
 


Test patch:
diff --git a/skimage/feature/tests/test_peak.py b/skimage/feature/tests/test_peak.py
--- a/skimage/feature/tests/test_peak.py
+++ b/skimage/feature/tests/test_peak.py
@@ -400,6 +400,19 @@ def test_threshold_rel_default(self):
             assert len(peak.peak_local_max(image,
                                            min_distance=0)) == image.size - 1
 
+    def test_peak_at_border(self):
+        image = np.full((10, 10), -2)
+        image[2, 4] = -1
+        image[3, 0] = -1
+
+        peaks = peak.peak_local_max(image, min_distance=3)
+        assert peaks.size == 0
+
+        peaks = peak.peak_local_max(image, min_distance=3, exclude_border=0)
+        assert len(peaks) == 2
+        assert [2, 4] in peaks
+        assert [3, 0] in peaks
+
 
 @pytest.mark.parametrize(
     ["indices"],