Skip to content

Commit 041b55b

Browse files
authored
Merge pull request #2237 from larrybradley/fix-kron-radius
Fix OOM errors due to unrealistically large Kron radius
2 parents d7f2161 + f2f7226 commit 041b55b

File tree

4 files changed

+306
-22
lines changed

4 files changed

+306
-22
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ Bug Fixes
215215
- Fixed an issue in ``SourceCatalog`` where incorrect masking behavior
216216
could occur when ``apermask_method='none'``. [#2198]
217217

218+
- Fixed an issue in ``SourceCatalog`` where unrealistically large
219+
``kron_radius`` values could cause out-of-memory issues. [#2237]
220+
218221
API Changes
219222
^^^^^^^^^^^
220223

docs/whats_new/3.0.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,22 @@ built-in catalog infrastructure (table conversion, filtering, slicing,
351351
etc.).
352352

353353

354+
Segmentation Improvements
355+
=========================
356+
357+
Protection against large Kron radii
358+
------------------------------------
359+
360+
:class:`~photutils.segmentation.SourceCatalog` now protects against
361+
unrealistically large ``kron_radius`` values that could cause
362+
out-of-memory errors. These can occur when noise or outlier pixels
363+
cause near-cancellation in the denominator of the Kron radius formula.
364+
``kron_radius`` values exceeding the measurement aperture scale factor
365+
(6.0) are now set to NaN, and aperture masks that would exceed the
366+
input data size are rejected. This prevents pathologically large Kron
367+
apertures from allocating excessive memory during photometry.
368+
369+
354370
New CentroidQuadratic class
355371
============================
356372

photutils/segmentation/catalog.py

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,16 +1522,28 @@ def centroid_win(self):
15221522
when the change in centroid position falls below a pre-defined
15231523
threshold or a maximum number of iterations is reached.
15241524
1525-
If the windowed centroid falls outside the 1-sigma ellipse shape
1526-
based on the image moments, then the isophotal `centroid` will
1527-
be used instead.
1525+
If the windowed centroid falls outside the 1-sigma ellipse
1526+
shape based on the image moments, then the isophotal `centroid`
1527+
will be used instead. If the half-light radius is not finite
1528+
(e.g., due to a non-finite Kron radius), then ``np.nan`` will be
1529+
returned.
15281530
"""
1529-
radius_hl = self.fluxfrac_radius(0.5).value
1531+
# Use .copy() to avoid mutating the cached fluxfrac_radius value
1532+
radius_hl = self.fluxfrac_radius(0.5).value.copy()
15301533
if self.isscalar:
15311534
radius_hl = np.array([radius_hl])
1532-
min_radius = 0.5 # define minimum half-light radius
1533-
mask = (radius_hl < min_radius) | ~np.isfinite(radius_hl)
1534-
radius_hl[mask] = min_radius
1535+
1536+
# Track which sources have non-finite half-light radii (e.g.,
1537+
# due to NaN kron_radius). These sources cannot have a
1538+
# meaningful windowed centroid.
1539+
nan_hl = ~np.isfinite(radius_hl)
1540+
1541+
# Apply a minimum half-light radius of 0.5 pixels (matching
1542+
# SourceExtractor) for valid but very small values
1543+
min_radius = 0.5
1544+
small_mask = np.isfinite(radius_hl) & (radius_hl < min_radius)
1545+
radius_hl[small_mask] = min_radius
1546+
15351547
kwargs = self._apermask_kwargs['cen_win']
15361548

15371549
labels = self.labels
@@ -1541,11 +1553,11 @@ def centroid_win(self):
15411553

15421554
xcen_win = []
15431555
ycen_win = []
1544-
for label, xcen, ycen, rad_hl in zip(labels, self._xcentroid,
1545-
self._ycentroid, radius_hl,
1546-
strict=True):
1556+
for label, xcen, ycen, rad_hl, nan_hl_ in zip(
1557+
labels, self._xcentroid, self._ycentroid, radius_hl,
1558+
nan_hl, strict=True):
15471559

1548-
if np.any(~np.isfinite((xcen, ycen))):
1560+
if nan_hl_ or np.any(~np.isfinite((xcen, ycen))):
15491561
xcen_win.append(np.nan)
15501562
ycen_win.append(np.nan)
15511563
continue
@@ -1560,7 +1572,11 @@ def centroid_win(self):
15601572
centroid_threshold = 0.0001
15611573
while iter_ < max_iters and dcen > centroid_threshold:
15621574
aperture = CircularAperture((xcen, ycen), radius)
1563-
aperture_mask = aperture.to_mask(**kwargs)
1575+
aperture_mask = self._aperture_to_mask(aperture, **kwargs)
1576+
if aperture_mask is None:
1577+
xcen = np.nan
1578+
ycen = np.nan
1579+
break
15641580

15651581
# For consistency with the isophotal centroid, a local
15661582
# background is not subtracted here
@@ -1607,7 +1623,9 @@ def centroid_win(self):
16071623
ycen_win = np.array(ycen_win)
16081624

16091625
# Reset to the isophotal centroid if the windowed centroid is
1610-
# outside the 1-sigma ellipse
1626+
# outside the 1-sigma ellipse or if the iteration failed (NaN
1627+
# from aperture off-image). Sources with NaN half-light radius
1628+
# keep NaN (no valid window size).
16111629
dx = self._xcentroid - xcen_win
16121630
dy = self._ycentroid - ycen_win
16131631
cxx = self.cxx.value
@@ -1617,11 +1635,12 @@ def centroid_win(self):
16171635
cxx = (cxx,)
16181636
cxy = (cxy,)
16191637
cyy = (cyy,)
1620-
mask = ((cxx * dx**2 + cxy * dx * dy + cyy * dy**2) > 1)
1621-
mask |= (np.isnan(xcen_win) | np.isnan(ycen_win))
1622-
if np.any(mask):
1623-
xcen_win[mask] = self._xcentroid[mask]
1624-
ycen_win[mask] = self._ycentroid[mask]
1638+
reset = ((cxx * dx**2 + cxy * dx * dy + cyy * dy**2) > 1)
1639+
nan_cen = np.isnan(xcen_win) | np.isnan(ycen_win)
1640+
reset |= nan_cen & ~nan_hl
1641+
if np.any(reset):
1642+
xcen_win[reset] = self._xcentroid[reset]
1643+
ycen_win[reset] = self._ycentroid[reset]
16251644

16261645
return np.transpose((xcen_win, ycen_win))
16271646

@@ -2878,6 +2897,27 @@ def local_background(self):
28782897
bkg <<= self._data_unit
28792898
return bkg
28802899

2900+
def _aperture_to_mask(self, aperture, **kwargs):
2901+
"""
2902+
Call ``aperture.to_mask()``, but first check that the aperture
2903+
bounding box is not larger than the input data to prevent
2904+
out-of-memory errors from pathologically large apertures.
2905+
2906+
The aperture mask is allocated at the full (unclipped) bounding
2907+
box size by ``to_mask()``, before ``get_overlap_slices`` clips
2908+
it to the data shape. For pathological apertures (e.g., from
2909+
huge Kron radii), this allocation can cause out-of-memory
2910+
issues.
2911+
2912+
Returns `None` if the aperture mask would be unreasonably large.
2913+
"""
2914+
bbox = aperture.bbox
2915+
# Limit the aperture mask size to prevent OOM errors
2916+
max_size = max(self._data.size, 1_000_000)
2917+
if bbox.shape[0] * bbox.shape[1] > max_size:
2918+
return None
2919+
return aperture.to_mask(**kwargs)
2920+
28812921
def _make_aperture_data(self, label, xcentroid, ycentroid, aperture_bbox,
28822922
local_background, *, make_error=True):
28832923
"""
@@ -3193,7 +3233,10 @@ def _measured_kron_radius(self):
31933233

31943234
xcen, ycen = aperture.positions
31953235
# Use 'center' (whole pixels) to compute Kron radius
3196-
aperture_mask = aperture.to_mask(method='center')
3236+
aperture_mask = self._aperture_to_mask(aperture, method='center')
3237+
if aperture_mask is None:
3238+
kron_radius.append(np.nan)
3239+
continue
31973240

31983241
# Prepare cutouts of the data based on the aperture size
31993242
# local background explicitly set to zero for SE agreement
@@ -3234,6 +3277,15 @@ def _calc_kron_radius(self, kron_params):
32343277
pixel units.
32353278
"""
32363279
kron_radius = self._measured_kron_radius.copy()
3280+
3281+
# Set values exceeding the measurement aperture scale (6.0)
3282+
# to NaN. Such values are unphysical (the Kron radius cannot
3283+
# meaningfully exceed the aperture used to measure it) and are
3284+
# caused by near-cancellation in the denominator of the Kron
3285+
# formula due to outlier pixels or noise.
3286+
max_kron_radius = 6.0
3287+
kron_radius[kron_radius > max_kron_radius] = np.nan
3288+
32373289
# Set minimum (unscaled) kron radius
32383290
kron_radius[kron_radius < kron_params[1]] = kron_params[1]
32393291

@@ -3280,7 +3332,11 @@ def kron_radius(self):
32803332
32813333
The `kron_radius` value is the unscaled moment value. The
32823334
minimum unscaled radius can be set using the second element of
3283-
the `SourceCatalog` ``kron_params`` keyword.
3335+
the `SourceCatalog` ``kron_params`` keyword. If the measured
3336+
unscaled Kron radius exceeds 6.0 (the measurement aperture
3337+
scale factor), ``np.nan`` will be returned. Such values are
3338+
unphysical, typically caused by near-cancellation in the
3339+
denominator of the Kron formula due to outlier pixels or noise.
32843340
32853341
If either the numerator or denominator above is less than
32863342
or equal to 0, then the minimum unscaled Kron radius
@@ -3505,7 +3561,11 @@ def _aperture_photometry(self, apertures, *, desc='', **kwargs):
35053561
continue
35063562

35073563
xcen, ycen = aperture.positions
3508-
aperture_mask = aperture.to_mask(**kwargs)
3564+
aperture_mask = self._aperture_to_mask(aperture, **kwargs)
3565+
if aperture_mask is None:
3566+
flux.append(np.nan)
3567+
fluxerr.append(np.nan)
3568+
continue
35093569

35103570
# Prepare cutouts of the data based on the aperture size
35113571
data, error, mask, _, slc_sm = self._make_aperture_data(
@@ -3735,7 +3795,10 @@ def _fluxfrac_optimizer_args(self):
37353795
continue
37363796

37373797
aperture = CircularAperture((xcen, ycen), r=max_radius_)
3738-
aperture_mask = aperture.to_mask(**kwargs)
3798+
aperture_mask = self._aperture_to_mask(aperture, **kwargs)
3799+
if aperture_mask is None:
3800+
args.append(None)
3801+
continue
37393802

37403803
# Prepare cutouts of the data based on the maximum aperture size
37413804
data, _, mask, xycen, _ = self._make_aperture_data(

0 commit comments

Comments
 (0)