1919__all__ = ['find_peaks' ]
2020
2121
22+ def _verify_ring_candidates (data , peak_mask , needs_verify , footprint_bool ,
23+ half , footprint_size ):
24+ """
25+ Verify ring candidates against the exact circular footprint.
26+
27+ Ring candidates are pixels that are the local maximum within the
28+ inscribed box but not in the circumscribed box. These need per-pixel
29+ verification against the actual circular footprint.
30+
31+ Parameters
32+ ----------
33+ data : 2D `~numpy.ndarray`
34+ The 2D image array.
35+
36+ peak_mask : 2D bool `~numpy.ndarray`
37+ Boolean mask to update in place. `True` indicates a confirmed
38+ local maximum.
39+
40+ needs_verify : 2D bool `~numpy.ndarray`
41+ Boolean mask of candidate pixels that require verification.
42+
43+ footprint_bool : 2D bool `~numpy.ndarray`
44+ The circular footprint boolean mask.
45+
46+ half : int
47+ Half the footprint size (``footprint_size // 2``), used to
48+ center the footprint on each candidate pixel.
49+
50+ footprint_size : int
51+ The size of the circular footprint array along each axis.
52+ """
53+ y_maybe , x_maybe = needs_verify .nonzero ()
54+ if len (y_maybe ) == 0 :
55+ return
56+
57+ ny , nx = data .shape
58+ for y , x in zip (y_maybe , x_maybe , strict = True ):
59+ # Map footprint onto data, clipping to image boundaries
60+ y0 = y - half
61+ y1 = y0 + footprint_size
62+ x0 = x - half
63+ x1 = x0 + footprint_size
64+
65+ dy0 , dy1 = max (0 , y0 ), min (ny , y1 )
66+ dx0 , dx1 = max (0 , x0 ), min (nx , x1 )
67+
68+ fy0 = dy0 - y0
69+ fy1 = footprint_size - (y1 - dy1 )
70+ fx0 = dx0 - x0
71+ fx1 = footprint_size - (x1 - dx1 )
72+
73+ local = data [dy0 :dy1 , dx0 :dx1 ]
74+ fp_local = footprint_bool [fy0 :fy1 , fx0 :fx1 ]
75+ local_max = local [fp_local ].max ()
76+
77+ # Footprint extends beyond image: include cval=0.0
78+ if (fy0 > 0 or fy1 < footprint_size or fx0 > 0
79+ or fx1 < footprint_size ):
80+ local_max = max (local_max , 0.0 )
81+
82+ # peak_mask is updated in place
83+ if data [y , x ] == local_max :
84+ peak_mask [y , x ] = True
85+
86+
2287def _fast_circular_peaks (data , radius ):
2388 """
2489 Find pixels that are local maxima within circular regions.
@@ -39,7 +104,9 @@ def _fast_circular_peaks(data, radius):
39104 Parameters
40105 ----------
41106 data : 2D `~numpy.ndarray`
42- The 2D image array (NaN-free).
107+ The 2D image array. Must be NaN-free because
108+ `~scipy.ndimage.maximum_filter` propagates NaNs, which would
109+ corrupt the local-maximum comparisons.
43110
44111 radius : float
45112 The radius of the circular region in pixels.
@@ -56,7 +123,7 @@ def _fast_circular_peaks(data, radius):
56123 footprint_size = len (idx )
57124
58125 xx , yy = np .meshgrid (idx , idx )
59- fp_bool = (xx ** 2 + yy ** 2 ) <= radius_sq
126+ footprint_bool = (xx ** 2 + yy ** 2 ) <= radius_sq
60127
61128 # For even-sized footprints (non-integer radius), scipy's
62129 # maximum_filter places the center at index ``footprint_size // 2``
@@ -92,36 +159,9 @@ def _fast_circular_peaks(data, radius):
92159 needs_verify = candidates & ~ definite
93160 peak_mask = definite .copy ()
94161
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
162+ # peak_mask is updated in place
163+ _verify_ring_candidates (data , peak_mask , needs_verify , footprint_bool ,
164+ half , footprint_size )
125165
126166 return peak_mask
127167
@@ -262,10 +302,21 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
262302
263303 A centroiding function can be input via the ``centroid_func``
264304 keyword to compute centroid coordinates with subpixel precision
265- within the input ``box_size`` or ``footprint``.
305+ within the input ``box_size`` or ``footprint``. Note that when
306+ ``min_separation`` is used, the centroid region size is determined
307+ by ``box_size`` (default 3), not by ``min_separation``.
308+
309+ The peak detection uses ``mode='constant'`` with ``cval=0.0`` for
310+ `~scipy.ndimage.maximum_filter`, which means pixels outside the
311+ image boundary are treated as zero. For images with all-negative
312+ values, this may suppress legitimate peaks near the borders.
313+
314+ Any NaN values in the input ``data`` are replaced with the minimum
315+ finite value before peak detection, and the corresponding pixels are
316+ automatically excluded from the results.
266317
267- The output column names (``x_peak``, ``y_peak``,
268- ``peak_value``) differ from the star finder classes (e.g.,
318+ The output column names (``x_peak``, ``y_peak``, ``peak_value``)
319+ differ from the star finder classes (e.g.,
269320 `~photutils.detection.DAOStarFinder`), which use ``x_centroid``,
270321 ``y_centroid``, and ``flux``.
271322 """
@@ -298,11 +349,14 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
298349 border_width = as_pair ('border_width' , border_width ,
299350 lower_bound = (0 , 1 ), upper_bound = data .shape )
300351
301- # Remove NaN values to avoid runtime warnings
352+ # Remove NaN values to avoid runtime warnings and exclude NaN pixels
353+ # from peak detection
302354 nan_mask = np .isnan (data )
303355 if np .any (nan_mask ):
304356 data = np .copy (data ) # ndarray
305357 data [nan_mask ] = nanmin (data )
358+ mask = (nan_mask if mask is None
359+ else np .asanyarray (mask ) | nan_mask )
306360
307361 # peak_goodmask: good pixels are True
308362 if min_separation is not None and min_separation > 0 :
@@ -318,7 +372,7 @@ def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None,
318372
319373 # Exclude peaks that are masked
320374 if mask is not None :
321- mask = np .asanyarray (mask )
375+ mask = np .asanyarray (mask , dtype = bool )
322376 if data .shape != mask .shape :
323377 msg = 'data and mask must have the same shape'
324378 raise ValueError (msg )
0 commit comments