Skip to content

Commit 3134fad

Browse files
authored
Merge pull request #2246 from larrybradley/peakfinder-minsep
Add min_separation keyword to find_peaks to improve performance
2 parents 1a07a83 + 21a701d commit 3134fad

File tree

5 files changed

+399
-20
lines changed

5 files changed

+399
-20
lines changed

CHANGES.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ New Features
5757
to the convolved data without the default kernel-based scaling.
5858
[#2202]
5959

60+
- Added a ``min_separation`` keyword to ``find_peaks``. The
61+
implementation uses fast separable box filters and is approximately
62+
10-400x faster than using an explicit circular footprint with
63+
``scipy.ndimage.maximum_filter`` (depending on the radius), while
64+
producing identical results. [#2246]
65+
6066
- ``photutils.profiles``
6167

6268
- Added a ``EnsquaredCurveOfGrowth`` class to compute a curve of

docs/whats_new/3.0.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,31 @@ input ``threshold`` is applied directly to the convolved data without
346346
the default DAOFIND kernel-based scaling factor.
347347

348348

349+
New ``min_separation`` parameter for ``find_peaks``
350+
---------------------------------------------------
351+
352+
:func:`~photutils.detection.find_peaks` now accepts a ``min_separation``
353+
keyword argument that enforces a minimum peak separation without
354+
requiring an explicit circular footprint. When set, each detected
355+
peak must be the maximum value (or equal to the maximum) within a
356+
circle of the given radius, which is equivalent to passing a circular
357+
``footprint`` of that radius to :func:`~scipy.ndimage.maximum_filter`::
358+
359+
>>> from photutils.detection import find_peaks
360+
>>> tbl = find_peaks(data, threshold=5.0, min_separation=12.5)
361+
362+
This approach is approximately 10–400x faster than using an explicit
363+
circular footprint with :func:`~scipy.ndimage.maximum_filter` (depending
364+
on the radius). The result is identical to the slow circular-footprint
365+
method.
366+
367+
This parameter is used internally by
368+
:class:`~photutils.detection.DAOStarFinder`,
369+
:class:`~photutils.detection.IRAFStarFinder`, and
370+
:class:`~photutils.detection.StarFinder` when ``min_separation > 0``
371+
is set.
372+
373+
349374
Performance improvements
350375
------------------------
351376

photutils/detection/core.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,15 @@ def _find_stars(convolved_data, kernel, threshold, *, min_separation=0.0,
101101
is returned if no sources are found.
102102
"""
103103
# Define a local footprint for the peak finder
104-
if min_separation == 0.0: # DAOStarFinder
104+
find_peaks_kwargs = {}
105+
if min_separation == 0: # use kernel-shape footprint
105106
if isinstance(kernel, np.ndarray):
106107
footprint = np.ones(kernel.shape)
107108
else:
108109
footprint = kernel.mask.astype(bool)
110+
find_peaks_kwargs['footprint'] = footprint
109111
else:
110-
# Define a local circular footprint for the peak finder
111-
idx = np.arange(-min_separation, min_separation + 1)
112-
xx, yy = np.meshgrid(idx, idx)
113-
footprint = np.array((xx**2 + yy**2) <= min_separation**2,
114-
dtype=int)
112+
find_peaks_kwargs['min_separation'] = min_separation
115113

116114
# Define the border exclusion region
117115
if exclude_border:
@@ -129,8 +127,8 @@ def _find_stars(convolved_data, kernel, threshold, *, min_separation=0.0,
129127
# Suppress any NoDetectionsWarning from find_peaks.
130128
with warnings.catch_warnings():
131129
warnings.filterwarnings('ignore', category=NoDetectionsWarning)
132-
tbl = find_peaks(convolved_data, threshold, footprint=footprint,
133-
mask=mask, border_width=border_width)
130+
tbl = find_peaks(convolved_data, threshold, mask=mask,
131+
border_width=border_width, **find_peaks_kwargs)
134132

135133
if tbl is None:
136134
return None

photutils/detection/peakfinder.py

Lines changed: 152 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,117 @@
1919
__all__ = ['find_peaks']
2020

2121

22+
def _fast_circular_peaks(data, radius):
23+
"""
24+
Find pixels that are local maxima within circular regions.
25+
26+
This is equivalent to::
27+
28+
idx = np.arange(-radius, radius + 1)
29+
xx, yy = np.meshgrid(idx, idx)
30+
footprint = np.array((xx**2 + yy**2) <= radius**2, dtype=int)
31+
data_max = maximum_filter(data, footprint=footprint,
32+
mode='constant', cval=0.0)
33+
peaks = (data == data_max)
34+
35+
but uses fast separable box filters with targeted circular
36+
verification, which is typically ~10-400x faster (depending on the
37+
radius).
38+
39+
Parameters
40+
----------
41+
data : 2D `~numpy.ndarray`
42+
The 2D image array (NaN-free).
43+
44+
radius : float
45+
The radius of the circular region in pixels.
46+
47+
Returns
48+
-------
49+
peak_mask : 2D bool `~numpy.ndarray`
50+
Boolean mask where `True` indicates a local maximum within the
51+
circular region.
52+
"""
53+
# Build the circular footprint
54+
idx = np.arange(-radius, radius + 1)
55+
radius_sq = radius ** 2
56+
footprint_size = len(idx)
57+
58+
xx, yy = np.meshgrid(idx, idx)
59+
fp_bool = (xx ** 2 + yy ** 2) <= radius_sq
60+
61+
# For even-sized footprints (non-integer radius), scipy's
62+
# maximum_filter places the center at index ``footprint_size // 2``
63+
# (i.e., the origin is biased by +0.5 pixel). The same convention is
64+
# used here so that the fast path is bit-identical to the reference
65+
# maximum_filter(footprint=...) result.
66+
half = footprint_size // 2
67+
68+
# Circumscribed box (size = footprint_size): contains the footprint.
69+
# Any pixel that is the max in this box is definitely the max in the
70+
# circular footprint, since circle <= box.
71+
data_max_box = maximum_filter(data, size=footprint_size, mode='constant',
72+
cval=0.0)
73+
definite = (data == data_max_box)
74+
75+
# Inscribed box: fits inside the circle. For even-sized footprints,
76+
# the circle center is shifted by 0.5 from the pixel center. We
77+
# account for this so the inscribed box stays inside the circle.
78+
if footprint_size % 2 == 0:
79+
half_side = int(np.floor(radius / np.sqrt(2) - 0.5))
80+
else:
81+
half_side = int(np.floor(radius / np.sqrt(2)))
82+
side_insc = max(2 * half_side + 1, 3)
83+
84+
data_max_insc = maximum_filter(data, size=side_insc, mode='constant',
85+
cval=0.0)
86+
# Candidates from inscribed box are a superset of true peaks
87+
candidates = (data == data_max_insc)
88+
89+
# Ring candidates: max in inscribed box but not in circumscribed
90+
# box. These need per-pixel verification against the actual circular
91+
# footprint.
92+
needs_verify = candidates & ~definite
93+
peak_mask = definite.copy()
94+
95+
y_maybe, x_maybe = needs_verify.nonzero()
96+
if len(y_maybe) > 0:
97+
ny, nx = data.shape
98+
for y, x in zip(y_maybe, x_maybe, strict=True):
99+
100+
# Map footprint onto data, clipping to image boundaries.
101+
y0 = y - half
102+
y1 = y0 + footprint_size
103+
x0 = x - half
104+
x1 = x0 + footprint_size
105+
106+
dy0, dy1 = max(0, y0), min(ny, y1)
107+
dx0, dx1 = max(0, x0), min(nx, x1)
108+
109+
fy0 = dy0 - y0
110+
fy1 = footprint_size - (y1 - dy1)
111+
fx0 = dx0 - x0
112+
fx1 = footprint_size - (x1 - dx1)
113+
114+
local = data[dy0:dy1, dx0:dx1]
115+
fp_local = fp_bool[fy0:fy1, fx0:fx1]
116+
local_max = local[fp_local].max()
117+
118+
# Footprint extends beyond image: include cval=0.0
119+
if (fy0 > 0 or fy1 < footprint_size or fx0 > 0
120+
or fx1 < footprint_size):
121+
local_max = max(local_max, 0.0)
122+
123+
if data[y, x] == local_max:
124+
peak_mask[y, x] = True
125+
126+
return peak_mask
127+
128+
22129
@deprecated_renamed_argument('npeaks', 'n_peaks', '3.0', until='4.0')
23130
def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
24-
border_width=None, n_peaks=np.inf, centroid_func=None,
25-
error=None, wcs=None):
131+
border_width=None, n_peaks=np.inf, min_separation=None,
132+
centroid_func=None, error=None, wcs=None):
26133
"""
27134
Find local peaks in an image that are above a specified threshold
28135
value.
@@ -39,6 +146,12 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
39146
defined region effectively imposes a minimum separation between
40147
peaks unless there are identical peaks within the region.
41148
149+
When ``min_separation`` is set, a fast algorithm is used that
150+
produces results equivalent to using a circular ``footprint`` of the
151+
given radius for `~scipy.ndimage.maximum_filter`, but is typically
152+
~10-400x faster (depending on the radius). When set, ``box_size``
153+
and ``footprint`` are not used for peak detection.
154+
42155
If ``centroid_func`` is input, then it will be used to calculate a
43156
centroid within the defined local region centered on each detected
44157
peak pixel. In this case, the centroid will also be returned in the
@@ -91,6 +204,17 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
91204
detected peaks exceeds ``n_peaks``, the peaks with the highest
92205
peak intensities will be returned.
93206
207+
min_separation : float or None, optional
208+
The minimum allowed separation (in pixels) between detected
209+
peaks, enforced using a circular region of this radius. Each
210+
detected peak must be the maximum value (or tied for the
211+
maximum) within a circle of this radius. This is equivalent to
212+
using a circular ``footprint`` of the given radius but uses a
213+
fast algorithm that is typically ~10-400x faster (depending on
214+
the radius). When set, ``box_size`` and ``footprint`` are not
215+
used for peak detection. If `None` (default), the peak detection
216+
uses ``box_size`` or ``footprint`` as specified.
217+
94218
centroid_func : callable, optional
95219
A callable object (e.g., function or class) that is used to
96220
calculate the centroid of a 2D array. The ``centroid_func``
@@ -126,13 +250,22 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
126250
-----
127251
By default, the returned pixel coordinates are the integer indices
128252
of the maximum pixel value within the input ``box_size`` or
129-
``footprint`` (i.e., only the peak pixel is identified). However, a
130-
centroiding function can be input via the ``centroid_func`` keyword
131-
to compute centroid coordinates with subpixel precision within the
132-
input ``box_size`` or ``footprint``.
133-
134-
The output column names (``x_peak``, ``y_peak``, ``peak_value``)
135-
differ from the star finder classes (e.g.,
253+
``footprint`` (i.e., only the peak pixel is identified).
254+
255+
When ``min_separation`` is given, peaks are detected
256+
using a fast algorithm that is mathematically equivalent
257+
to a circular ``footprint`` of the given radius for
258+
`~scipy.ndimage.maximum_filter`. The algorithm uses two fast O(N)
259+
separable box filters (inscribed and circumscribed squares of
260+
the circle) to classify most candidates, then verifies only the
261+
remaining few against the exact circular region.
262+
263+
A centroiding function can be input via the ``centroid_func``
264+
keyword to compute centroid coordinates with subpixel precision
265+
within the input ``box_size`` or ``footprint``.
266+
267+
The output column names (``x_peak``, ``y_peak``,
268+
``peak_value``) differ from the star finder classes (e.g.,
136269
`~photutils.detection.DAOStarFinder`), which use ``x_centroid``,
137270
``y_centroid``, and ``flux``.
138271
"""
@@ -145,6 +278,10 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
145278
msg = 'centroid_func must be a callable object'
146279
raise TypeError(msg)
147280

281+
if min_separation is not None and min_separation < 0:
282+
msg = 'min_separation must be >= 0'
283+
raise ValueError(msg)
284+
148285
if np.all(data == data.flat[0]):
149286
msg = 'Input data is constant. No local peaks can be found.'
150287
warnings.warn(msg, NoDetectionsWarning)
@@ -167,14 +304,17 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
167304
data = np.copy(data) # ndarray
168305
data[nan_mask] = nanmin(data)
169306

170-
if footprint is not None:
307+
# peak_goodmask: good pixels are True
308+
if min_separation is not None and min_separation > 0:
309+
peak_goodmask = _fast_circular_peaks(data, min_separation)
310+
elif footprint is not None:
171311
data_max = maximum_filter(data, footprint=footprint, mode='constant',
172312
cval=0.0)
313+
peak_goodmask = (data == data_max)
173314
else:
174315
data_max = maximum_filter(data, size=box_size, mode='constant',
175316
cval=0.0)
176-
177-
peak_goodmask = (data == data_max) # good pixels are True
317+
peak_goodmask = (data == data_max)
178318

179319
# Exclude peaks that are masked
180320
if mask is not None:

0 commit comments

Comments
 (0)