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' )
23130def 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