Skip to content

Commit 8defb67

Browse files
authored
Merge pull request #2247 from larrybradley/find-peaks-fix
Fix find_peaks edge case with NaN pixels
2 parents 3134fad + 40e74b5 commit 8defb67

File tree

8 files changed

+323
-43
lines changed

8 files changed

+323
-43
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ Bug Fixes
198198
- Fixed ``StarFinder`` mutating the input ``kernel`` array during
199199
normalization. [#2201]
200200

201+
- Fixed an edge case of ``find_peaks`` not excluding NaN pixel
202+
locations from peak detection, which could produce false peaks when
203+
the NaN fill value was a local maximum. [#2247]
204+
201205
- ``photutils.isophote``
202206

203207
- ``build_ellipse_model`` now integrates over all angles instead of

photutils/detection/core.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,26 @@ class StarFinderBase(metaclass=abc.ABCMeta):
4949

5050
@deprecated_positional_kwargs(since='3.0', until='4.0')
5151
def __call__(self, data, mask=None):
52+
"""
53+
Find stars in an astronomical image.
54+
55+
Parameters
56+
----------
57+
data : 2D array_like
58+
The 2D image array.
59+
60+
mask : 2D bool array, optional
61+
A boolean mask with the same shape as ``data``, where a
62+
`True` value indicates the corresponding element of ``data``
63+
is masked. Masked pixels are ignored when searching for
64+
stars.
65+
66+
Returns
67+
-------
68+
table : `~astropy.table.Table` or `None`
69+
A table of found stars. If no stars are found then `None` is
70+
returned.
71+
"""
5272
return self.find_stars(data, mask=mask)
5373

5474
@staticmethod
@@ -60,7 +80,8 @@ def _find_stars(convolved_data, kernel, threshold, *, min_separation=0.0,
6080
Parameters
6181
----------
6282
convolved_data : 2D array_like
63-
The convolved 2D array.
83+
The convolved 2D array. Should be NaN-free; any NaN values
84+
should be handled before calling this method.
6485
6586
kernel : `_StarFinderKernel` or 2D `~numpy.ndarray`
6687
The convolution kernel. ``StarFinder`` inputs the kernel
@@ -231,6 +252,7 @@ def __init__(self, data, xypos, kernel, *, n_brightest=None,
231252
self.xypos = np.atleast_2d(xypos)
232253
self.n_brightest = n_brightest
233254
self.peak_max = peak_max
255+
self.default_columns = ()
234256

235257
self.id = np.arange(len(self)) + 1
236258

@@ -297,7 +319,7 @@ def _get_init_attributes(self):
297319
This method should be overridden in subclasses.
298320
"""
299321
return ('data', 'unit', 'kernel', 'n_brightest', 'peak_max',
300-
'cutout_shape')
322+
'cutout_shape', 'default_columns')
301323

302324
@property
303325
def _lazyproperties(self):
@@ -583,7 +605,7 @@ def _filter_finite(self, attrs, *, initial_mask=None,
583605

584606
return newcat
585607

586-
def _filter_bounds(self, bounds, *, peakattr='peak'):
608+
def _filter_bounds(self, bounds, *, initial_mask=None, peakattr='peak'):
587609
"""
588610
Filter the catalog by sharpness, roundness, and peak_max bounds.
589611
@@ -595,6 +617,10 @@ def _filter_bounds(self, bounds, *, peakattr='peak'):
595617
of the form ``(lower_bound, upper_bound)``, or `None` to
596618
skip filtering for that attribute.
597619
620+
initial_mask : 1D `~numpy.ndarray` of bool or `None`, optional
621+
A pre-existing boolean mask to combine with. If `None`,
622+
starts with all `True`.
623+
598624
peakattr : str, optional
599625
The attribute name for the peak value used for peak_max
600626
filtering. The default is ``'peak'``.
@@ -604,7 +630,11 @@ def _filter_bounds(self, bounds, *, peakattr='peak'):
604630
catalog : ``self.__class__`` or `None`
605631
The filtered catalog, or `None` if no sources remain.
606632
"""
607-
mask = np.ones(len(self), dtype=bool)
633+
if initial_mask is None:
634+
mask = np.ones(len(self), dtype=bool)
635+
else:
636+
mask = initial_mask.copy()
637+
608638
for attr, range_val in bounds:
609639
if range_val is None:
610640
continue
@@ -670,7 +700,7 @@ def to_table(self, *, columns=None):
670700

671701
table.meta.update(_get_meta()) # keep table.meta type
672702
if columns is None:
673-
if not hasattr(self, 'default_columns'):
703+
if not self.default_columns:
674704
msg = ('default_columns attribute is not set; either '
675705
'pass explicit column names or set '
676706
'default_columns in the subclass __init__')

photutils/detection/peakfinder.py

Lines changed: 91 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,71 @@
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+
2287
def _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)

photutils/detection/tests/test_core.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import numpy as np
77
import pytest
8+
from astropy.utils.exceptions import AstropyDeprecationWarning
89

910
from photutils.detection import DAOStarFinder
1011
from photutils.detection.core import (_DEPR_DEFAULT, StarFinderCatalogBase,
@@ -226,7 +227,7 @@ def test_get_init_attributes(self, minimal_catalog_cls):
226227
xypos = np.array([[5, 5]])
227228
cat = minimal_catalog_cls(data, xypos, kernel)
228229
expected = ('data', 'unit', 'kernel', 'n_brightest', 'peak_max',
229-
'cutout_shape')
230+
'cutout_shape', 'default_columns')
230231
assert cat._get_init_attributes() == expected
231232

232233
def test_lazyproperties_class_cache(self, minimal_catalog_cls):
@@ -520,6 +521,51 @@ def test_getitem_integer_array(self, minimal_catalog_cls):
520521
assert sub.xypos[0, 0] == 15
521522
assert sub.xypos[1, 0] == 5
522523

524+
def test_filter_bounds_none_range(self, minimal_catalog_cls):
525+
"""
526+
Test that _filter_bounds skips filtering when a range is None.
527+
"""
528+
data = np.zeros((21, 21))
529+
data[5, 5] = 10.0
530+
data[10, 10] = 50.0
531+
data[15, 15] = 30.0
532+
kernel = np.ones((3, 3))
533+
xypos = np.array([[5, 5], [10, 10], [15, 15]])
534+
cat = minimal_catalog_cls(data, xypos, kernel)
535+
bounds = [('flux', None)]
536+
result = cat._filter_bounds(bounds)
537+
assert len(result) == 3
538+
539+
def test_filter_bounds_initial_mask(self, minimal_catalog_cls):
540+
"""
541+
Test _filter_bounds with an initial_mask.
542+
"""
543+
data = np.zeros((21, 21))
544+
data[5, 5] = 10.0
545+
data[10, 10] = 50.0
546+
data[15, 15] = 30.0
547+
kernel = np.ones((3, 3))
548+
xypos = np.array([[5, 5], [10, 10], [15, 15]])
549+
cat = minimal_catalog_cls(data, xypos, kernel)
550+
# Pre-exclude the first source
551+
initial_mask = np.array([False, True, True])
552+
result = cat._filter_bounds([], initial_mask=initial_mask)
553+
assert len(result) == 2
554+
555+
def test_default_columns_preserved_on_slice(self, minimal_catalog_cls):
556+
"""
557+
Test that default_columns is preserved when slicing.
558+
"""
559+
data = np.zeros((21, 21))
560+
data[5, 5] = 10.0
561+
data[10, 10] = 50.0
562+
kernel = np.ones((3, 3))
563+
xypos = np.array([[5, 5], [10, 10]])
564+
cat = minimal_catalog_cls(data, xypos, kernel)
565+
cat.default_columns = ('id', 'x_centroid', 'y_centroid')
566+
sub = cat[0]
567+
assert sub.default_columns == ('id', 'x_centroid', 'y_centroid')
568+
523569

524570
class TestStarFinderBaseCall:
525571
"""
@@ -538,6 +584,18 @@ def test_call_delegates_to_find_stars(self, data):
538584
np.testing.assert_array_equal(tbl_call[col], tbl_find[col])
539585

540586

587+
def test_deprecated_attr(data):
588+
"""
589+
Test that accessing the deprecated attribute on the
590+
StarFinderCatalogBase raises an warning.
591+
"""
592+
finder = DAOStarFinder(threshold=5.0, fwhm=2.0)
593+
cat = finder._get_raw_catalog(data)
594+
match = 'attribute was deprecated'
595+
with pytest.warns(AstropyDeprecationWarning, match=match):
596+
_ = cat.xcentroid
597+
598+
541599
def test_deprecated_default():
542600
"""
543601
Test repr for _DeprecatedDefault.

photutils/detection/tests/test_daofinder.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ def test_mask(self, data):
128128
tbl1 = finder(data, mask=mask)
129129
assert len(tbl0) > len(tbl1)
130130

131+
def test_mask_int(self, data):
132+
"""
133+
Test that an integer mask gives the same result as a boolean
134+
mask.
135+
"""
136+
finder = DAOStarFinder(threshold=1.0, fwhm=1.5)
137+
bool_mask = np.zeros(data.shape, dtype=bool)
138+
bool_mask[0:50, :] = True
139+
int_mask = bool_mask.astype(int)
140+
141+
tbl_bool = finder(data, mask=bool_mask)
142+
tbl_int = finder(data, mask=int_mask)
143+
assert_array_equal(tbl_bool, tbl_int)
144+
131145
def test_xycoords(self, data):
132146
"""
133147
Test source detection at specified coordinates.

0 commit comments

Comments
 (0)