stdpipe 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
stdpipe/photometry.py
ADDED
|
@@ -0,0 +1,2340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Routines for object detection and photometry.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os, shutil, tempfile, shlex
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from astropy.wcs import WCS
|
|
9
|
+
from astropy.io import fits
|
|
10
|
+
from astropy.stats import mad_std, sigma_clipped_stats
|
|
11
|
+
from astropy.table import Table
|
|
12
|
+
|
|
13
|
+
import warnings
|
|
14
|
+
from astropy.wcs import FITSFixedWarning
|
|
15
|
+
|
|
16
|
+
# warnings.simplefilter(action='ignore', category=FITSFixedWarning)
|
|
17
|
+
# warnings.simplefilter(action='ignore', category=FutureWarning)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import sep_x as sep
|
|
21
|
+
except ImportError:
|
|
22
|
+
import sep
|
|
23
|
+
import photutils
|
|
24
|
+
import photutils.background
|
|
25
|
+
import photutils.aperture
|
|
26
|
+
import photutils.segmentation
|
|
27
|
+
import photutils.detection
|
|
28
|
+
|
|
29
|
+
# Put these to common namespace
|
|
30
|
+
from .photometry_model import match, make_sn_model, get_detection_limit_sn, format_color_term
|
|
31
|
+
from .photometry_measure import measure_objects, _is_callable_fwhm, _fwhm_median
|
|
32
|
+
|
|
33
|
+
# Conditionally import SEP-based photometry if available
|
|
34
|
+
try:
|
|
35
|
+
from .photometry_measure import measure_objects_sep, _HAS_SEP_OPTIMAL
|
|
36
|
+
except ImportError:
|
|
37
|
+
_HAS_SEP_OPTIMAL = False
|
|
38
|
+
|
|
39
|
+
from .photometry_background import (
|
|
40
|
+
get_background,
|
|
41
|
+
get_background_percentile,
|
|
42
|
+
get_background_morphology,
|
|
43
|
+
estimate_background_rms_percentile,
|
|
44
|
+
estimate_background_rms_local,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import cv2
|
|
49
|
+
|
|
50
|
+
# Much faster dilation
|
|
51
|
+
dilate = lambda image, mask: cv2.dilate(image.astype(np.uint8), mask).astype(bool)
|
|
52
|
+
except:
|
|
53
|
+
from scipy.signal import fftconvolve
|
|
54
|
+
|
|
55
|
+
dilate = lambda image, mask: fftconvolve(image, mask, mode='same') > 0.9
|
|
56
|
+
|
|
57
|
+
from . import utils
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def make_kernel(r0=1.0, ext=1.0):
|
|
61
|
+
x, y = np.mgrid[
|
|
62
|
+
np.floor(-ext * r0) : np.ceil(ext * r0 + 1),
|
|
63
|
+
np.floor(-ext * r0) : np.ceil(ext * r0 + 1),
|
|
64
|
+
]
|
|
65
|
+
r = np.hypot(x, y)
|
|
66
|
+
image = np.exp(-(r**2) / 2 / r0**2)
|
|
67
|
+
|
|
68
|
+
return image
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def estimate_fwhm(values, *, good=None, fwhm_range=(1.0, 20.0), min_candidates=5):
|
|
72
|
+
"""Robust global FWHM estimate from per-object FWHM measurements.
|
|
73
|
+
|
|
74
|
+
Uses a sliding-window mode estimator (narrowest quartile interval) that is
|
|
75
|
+
resistant to outliers from galaxies, blends, and non-Gaussian tails.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
values : array-like
|
|
80
|
+
1-D array of per-object FWHM values in pixels.
|
|
81
|
+
good : array-like of bool, optional
|
|
82
|
+
Boolean mask, True = use this object. The caller is responsible for
|
|
83
|
+
applying any S/N, flag, or ellipticity cuts before calling, or
|
|
84
|
+
encoding them in this mask.
|
|
85
|
+
fwhm_range : tuple of float
|
|
86
|
+
Hard clip range ``(lo, hi)`` in pixels. Values outside this range
|
|
87
|
+
are excluded before the mode calculation.
|
|
88
|
+
min_candidates : int
|
|
89
|
+
Minimum number of surviving candidates required.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
float
|
|
94
|
+
Scalar FWHM estimate in pixels, or ``np.nan`` on failure.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
values = np.asarray(values, dtype=float).ravel()
|
|
98
|
+
mask = np.isfinite(values)
|
|
99
|
+
|
|
100
|
+
if good is not None:
|
|
101
|
+
mask &= np.asarray(good, dtype=bool).ravel()
|
|
102
|
+
|
|
103
|
+
mask &= (values >= fwhm_range[0]) & (values < fwhm_range[1])
|
|
104
|
+
|
|
105
|
+
f = np.sort(values[mask])
|
|
106
|
+
|
|
107
|
+
if len(f) < min_candidates:
|
|
108
|
+
return np.nan
|
|
109
|
+
|
|
110
|
+
if len(f) == 1:
|
|
111
|
+
return float(f[0])
|
|
112
|
+
|
|
113
|
+
# Sliding-window mode: find the narrowest interval spanning n//4 objects
|
|
114
|
+
nw = max(len(f) // 4, 1)
|
|
115
|
+
widths = f[nw:] - f[:-nw]
|
|
116
|
+
i = int(np.argmin(widths))
|
|
117
|
+
|
|
118
|
+
return float(0.5 * (f[i] + f[i + nw]))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _weighted_median(values, weights):
|
|
122
|
+
"""Weighted median of *values* using *weights*.
|
|
123
|
+
|
|
124
|
+
Both arrays must already be filtered to finite, positive entries.
|
|
125
|
+
Returns ``np.nan`` if fewer than 2 values survive.
|
|
126
|
+
"""
|
|
127
|
+
if len(values) < 2 or not np.any(weights > 0):
|
|
128
|
+
return np.nan
|
|
129
|
+
idx = np.argsort(values)
|
|
130
|
+
cumw = np.cumsum(weights[idx])
|
|
131
|
+
cumw /= cumw[-1]
|
|
132
|
+
return float(values[idx[np.searchsorted(cumw, 0.5)]])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _poly_terms(order):
|
|
136
|
+
"""Yield 2-D polynomial term exponents ``(i, j)`` with ``i + j <= order``,
|
|
137
|
+
``j`` varying fastest. Shared by :func:`_fit_spatial_fwhm` (design matrix)
|
|
138
|
+
and :class:`FWHMMap` (evaluation) so the two always agree."""
|
|
139
|
+
for i in range(order + 1):
|
|
140
|
+
for j in range(order + 1 - i):
|
|
141
|
+
yield i, j
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class FWHMMap:
|
|
145
|
+
"""Position-dependent FWHM estimator backed by a 2-D polynomial.
|
|
146
|
+
|
|
147
|
+
Produced by :func:`estimate_fwhm_from_objects` when ``spatial_order >= 1``.
|
|
148
|
+
Coordinates are normalised internally to roughly ``[-1, 1]`` for numerical
|
|
149
|
+
stability.
|
|
150
|
+
|
|
151
|
+
Instances are callable: ``fwhm_map(x, y)`` returns FWHM in pixels at the
|
|
152
|
+
requested pixel coordinates (``x``, ``y`` may be scalars or broadcastable
|
|
153
|
+
arrays; the result has the broadcast shape). Instances are also
|
|
154
|
+
convertible via ``float(fwhm_map)`` to the scalar median summary, so they
|
|
155
|
+
can be dropped into APIs that still expect a single number.
|
|
156
|
+
|
|
157
|
+
Attributes
|
|
158
|
+
----------
|
|
159
|
+
coeffs : ndarray
|
|
160
|
+
Flat array of polynomial coefficients, ordered by
|
|
161
|
+
``(i, j)`` with ``i + j <= order`` and ``j`` varied fastest.
|
|
162
|
+
order : int
|
|
163
|
+
Polynomial order.
|
|
164
|
+
x0, y0, sx, sy : float
|
|
165
|
+
Coordinate normalisation: ``xn = (x - x0) / sx``.
|
|
166
|
+
median : float
|
|
167
|
+
Representative scalar FWHM (median of the fitted surface over the
|
|
168
|
+
good candidates). Used by ``float(self)``.
|
|
169
|
+
n_used : int
|
|
170
|
+
Number of candidates retained after sigma-clipping.
|
|
171
|
+
fwhm_range : tuple of float
|
|
172
|
+
Output clipping range in pixels.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
__slots__ = (
|
|
176
|
+
'coeffs',
|
|
177
|
+
'order',
|
|
178
|
+
'x0',
|
|
179
|
+
'y0',
|
|
180
|
+
'sx',
|
|
181
|
+
'sy',
|
|
182
|
+
'median',
|
|
183
|
+
'n_used',
|
|
184
|
+
'fwhm_range',
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def __init__(self, coeffs, order, x0, y0, sx, sy, median, n_used, fwhm_range):
|
|
188
|
+
self.coeffs = np.asarray(coeffs, dtype=float).ravel()
|
|
189
|
+
self.order = int(order)
|
|
190
|
+
self.x0 = float(x0)
|
|
191
|
+
self.y0 = float(y0)
|
|
192
|
+
self.sx = float(sx)
|
|
193
|
+
self.sy = float(sy)
|
|
194
|
+
self.median = float(median)
|
|
195
|
+
self.n_used = int(n_used)
|
|
196
|
+
self.fwhm_range = (float(fwhm_range[0]), float(fwhm_range[1]))
|
|
197
|
+
|
|
198
|
+
def __call__(self, x, y):
|
|
199
|
+
x = np.asarray(x, dtype=float)
|
|
200
|
+
y = np.asarray(y, dtype=float)
|
|
201
|
+
xn = (x - self.x0) / self.sx
|
|
202
|
+
yn = (y - self.y0) / self.sy
|
|
203
|
+
shape = np.broadcast_shapes(xn.shape, yn.shape)
|
|
204
|
+
xn = np.broadcast_to(xn, shape)
|
|
205
|
+
yn = np.broadcast_to(yn, shape)
|
|
206
|
+
out = np.zeros(shape, dtype=float)
|
|
207
|
+
for k, (i, j) in enumerate(_poly_terms(self.order)):
|
|
208
|
+
out = out + self.coeffs[k] * (xn**i) * (yn**j)
|
|
209
|
+
return np.clip(out, self.fwhm_range[0], self.fwhm_range[1])
|
|
210
|
+
|
|
211
|
+
def __float__(self):
|
|
212
|
+
return self.median
|
|
213
|
+
|
|
214
|
+
def __repr__(self):
|
|
215
|
+
return "FWHMMap(order=%d, median=%.3f, n_used=%d)" % (self.order, self.median, self.n_used)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _fit_spatial_fwhm(
|
|
219
|
+
values,
|
|
220
|
+
x,
|
|
221
|
+
y,
|
|
222
|
+
good,
|
|
223
|
+
*,
|
|
224
|
+
order,
|
|
225
|
+
fwhm_range,
|
|
226
|
+
image_shape=None,
|
|
227
|
+
clip_sigma=3.0,
|
|
228
|
+
clip_iters=2,
|
|
229
|
+
log=None,
|
|
230
|
+
):
|
|
231
|
+
"""Fit a 2-D polynomial to per-object FWHM with sigma-clipping.
|
|
232
|
+
|
|
233
|
+
Returns a :class:`FWHMMap` on success, or ``None`` when fewer than
|
|
234
|
+
``3 * nterms`` candidates survive (so callers can fall back to the scalar
|
|
235
|
+
path).
|
|
236
|
+
"""
|
|
237
|
+
_log = log or (lambda *a, **k: None)
|
|
238
|
+
values = np.asarray(values, dtype=float)
|
|
239
|
+
x = np.asarray(x, dtype=float)
|
|
240
|
+
y = np.asarray(y, dtype=float)
|
|
241
|
+
good = np.asarray(good, dtype=bool)
|
|
242
|
+
good = good & np.isfinite(values) & np.isfinite(x) & np.isfinite(y)
|
|
243
|
+
good = good & (values >= fwhm_range[0]) & (values < fwhm_range[1])
|
|
244
|
+
|
|
245
|
+
nterms = (order + 1) * (order + 2) // 2
|
|
246
|
+
if good.sum() < 3 * nterms:
|
|
247
|
+
_log(
|
|
248
|
+
"Spatial FWHM fit: only %d candidates for %d terms — "
|
|
249
|
+
"falling back to scalar" % (int(good.sum()), nterms)
|
|
250
|
+
)
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
# Work on the candidate subset only
|
|
254
|
+
vals = values[good]
|
|
255
|
+
xg = x[good]
|
|
256
|
+
yg = y[good]
|
|
257
|
+
|
|
258
|
+
# Coordinate normalisation
|
|
259
|
+
if image_shape is not None:
|
|
260
|
+
H, W = image_shape[:2]
|
|
261
|
+
x0 = 0.5 * float(W)
|
|
262
|
+
y0 = 0.5 * float(H)
|
|
263
|
+
sx = max(0.5 * float(W), 1.0)
|
|
264
|
+
sy = max(0.5 * float(H), 1.0)
|
|
265
|
+
else:
|
|
266
|
+
x0 = 0.5 * (xg.min() + xg.max())
|
|
267
|
+
y0 = 0.5 * (yg.min() + yg.max())
|
|
268
|
+
sx = max(0.5 * (xg.max() - xg.min()), 1.0)
|
|
269
|
+
sy = max(0.5 * (yg.max() - yg.min()), 1.0)
|
|
270
|
+
|
|
271
|
+
xn = (xg - x0) / sx
|
|
272
|
+
yn = (yg - y0) / sy
|
|
273
|
+
|
|
274
|
+
D = np.column_stack([(xn**i) * (yn**j) for i, j in _poly_terms(order)])
|
|
275
|
+
|
|
276
|
+
mask = np.ones(len(vals), dtype=bool)
|
|
277
|
+
coeffs = None
|
|
278
|
+
# Some numpy/BLAS builds emit spurious fp warnings from matmul even on
|
|
279
|
+
# well-formed inputs; suppress them for the fit's own arithmetic.
|
|
280
|
+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
|
|
281
|
+
for it in range(max(clip_iters, 1)):
|
|
282
|
+
if mask.sum() < nterms:
|
|
283
|
+
break
|
|
284
|
+
coeffs, *_ = np.linalg.lstsq(D[mask], vals[mask], rcond=None)
|
|
285
|
+
resid = vals[mask] - D[mask] @ coeffs
|
|
286
|
+
if it == clip_iters - 1:
|
|
287
|
+
break
|
|
288
|
+
sig = mad_std(resid, ignore_nan=True)
|
|
289
|
+
if not np.isfinite(sig) or sig == 0:
|
|
290
|
+
break
|
|
291
|
+
idx = np.flatnonzero(mask)
|
|
292
|
+
mask = np.zeros_like(mask)
|
|
293
|
+
mask[idx[np.abs(resid) < clip_sigma * sig]] = True
|
|
294
|
+
|
|
295
|
+
if coeffs is None:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
n_used = int(mask.sum())
|
|
299
|
+
if n_used == 0:
|
|
300
|
+
return None
|
|
301
|
+
fit_vals = np.clip(D[mask] @ coeffs, fwhm_range[0], fwhm_range[1])
|
|
302
|
+
median = float(np.nanmedian(fit_vals))
|
|
303
|
+
if not np.isfinite(median):
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
return FWHMMap(
|
|
307
|
+
coeffs=coeffs,
|
|
308
|
+
order=order,
|
|
309
|
+
x0=x0,
|
|
310
|
+
y0=y0,
|
|
311
|
+
sx=sx,
|
|
312
|
+
sy=sy,
|
|
313
|
+
median=median,
|
|
314
|
+
n_used=n_used,
|
|
315
|
+
fwhm_range=fwhm_range,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _fwhm_value_candidates(obj, names, a=None, b=None):
|
|
320
|
+
"""Per-object FWHM proxies available in a detection table, in priority
|
|
321
|
+
order: half-light radius (most stable), then FWHM column, then second
|
|
322
|
+
moments. Returns a list of ``(source_name, values)`` pairs.
|
|
323
|
+
|
|
324
|
+
For a Gaussian profile ``FWHM = 2 * R_half``. This slightly
|
|
325
|
+
underestimates for Moffat profiles but the mode estimator naturally
|
|
326
|
+
centres on the stellar peak, compensating for the conversion factor
|
|
327
|
+
difference.
|
|
328
|
+
"""
|
|
329
|
+
candidates = []
|
|
330
|
+
|
|
331
|
+
fr_col = (
|
|
332
|
+
'flux_radius'
|
|
333
|
+
if 'flux_radius' in names
|
|
334
|
+
else 'FLUX_RADIUS'
|
|
335
|
+
if 'FLUX_RADIUS' in names
|
|
336
|
+
else None
|
|
337
|
+
)
|
|
338
|
+
if fr_col is not None:
|
|
339
|
+
candidates.append(('flux_radius', 2.0 * np.asarray(obj[fr_col], dtype=float)))
|
|
340
|
+
|
|
341
|
+
if 'fwhm' in names:
|
|
342
|
+
candidates.append(('fwhm', np.asarray(obj['fwhm'], dtype=float)))
|
|
343
|
+
elif 'FWHM_IMAGE' in names:
|
|
344
|
+
candidates.append(('FWHM_IMAGE', np.asarray(obj['FWHM_IMAGE'], dtype=float)))
|
|
345
|
+
elif a is not None and b is not None:
|
|
346
|
+
candidates.append(('moments', 2.0 * np.sqrt(np.log(2) * (a**2 + b**2))))
|
|
347
|
+
|
|
348
|
+
return candidates
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _estimate_fwhm_with_guard(values, good, ab, fwhm_range, min_candidates, log, label):
|
|
352
|
+
"""Robust mode FWHM with the ``a*b``-weighted-median contamination guard.
|
|
353
|
+
|
|
354
|
+
The weighted median replaces the mode when it exceeds it by more than
|
|
355
|
+
10% — the signature of the mode being biased low by sub-stellar
|
|
356
|
+
contaminants (cosmic rays, hot pixels).
|
|
357
|
+
|
|
358
|
+
Returns ``(estimate, mode_ok)`` where ``mode_ok`` tells whether the mode
|
|
359
|
+
estimation itself succeeded, so callers may fall back to another FWHM
|
|
360
|
+
proxy otherwise. ``estimate`` may be NaN when nothing usable survives.
|
|
361
|
+
"""
|
|
362
|
+
mode = estimate_fwhm(values, good=good, fwhm_range=fwhm_range, min_candidates=min_candidates)
|
|
363
|
+
|
|
364
|
+
if ab is not None:
|
|
365
|
+
valid = good & np.isfinite(values) & (values >= fwhm_range[0]) & (values < fwhm_range[1])
|
|
366
|
+
weighted = _weighted_median(values[valid], ab[valid])
|
|
367
|
+
else:
|
|
368
|
+
weighted = np.nan
|
|
369
|
+
|
|
370
|
+
if np.isfinite(mode) and np.isfinite(weighted):
|
|
371
|
+
ratio = weighted / mode
|
|
372
|
+
if ratio > 1.1:
|
|
373
|
+
log(
|
|
374
|
+
"FWHM (%s): mode=%.2f, ab-weighted median=%.2f (ratio %.2f) "
|
|
375
|
+
"— mode likely biased by sub-stellar objects, using weighted median"
|
|
376
|
+
% (label, mode, weighted, ratio)
|
|
377
|
+
)
|
|
378
|
+
return weighted, True
|
|
379
|
+
log(
|
|
380
|
+
"FWHM (%s): mode=%.2f, ab-weighted median=%.2f (ratio %.2f) — consistent"
|
|
381
|
+
% (label, mode, weighted, ratio)
|
|
382
|
+
)
|
|
383
|
+
return mode, True
|
|
384
|
+
|
|
385
|
+
if np.isfinite(mode):
|
|
386
|
+
log("FWHM (%s): mode=%.2f" % (label, mode))
|
|
387
|
+
return mode, True
|
|
388
|
+
|
|
389
|
+
if np.isfinite(weighted):
|
|
390
|
+
log(
|
|
391
|
+
"FWHM (%s): mode estimation failed (too few candidates), "
|
|
392
|
+
"falling back to ab-weighted median %.2f" % (label, weighted)
|
|
393
|
+
)
|
|
394
|
+
else:
|
|
395
|
+
log("FWHM (%s): estimation failed — no usable candidates" % label)
|
|
396
|
+
|
|
397
|
+
return weighted, False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def estimate_fwhm_from_objects(
|
|
401
|
+
obj,
|
|
402
|
+
*,
|
|
403
|
+
snr_min=10.0,
|
|
404
|
+
max_ellipticity=0.3,
|
|
405
|
+
use_flags=True,
|
|
406
|
+
fwhm_range=(1.0, 20.0),
|
|
407
|
+
min_candidates=5,
|
|
408
|
+
spatial_order=0,
|
|
409
|
+
xy_cols=('x', 'y'),
|
|
410
|
+
image_shape=None,
|
|
411
|
+
verbose=False,
|
|
412
|
+
):
|
|
413
|
+
"""Estimate global FWHM from an object table (SEP or SExtractor).
|
|
414
|
+
|
|
415
|
+
Extracts per-object FWHM values and builds a quality mask from the
|
|
416
|
+
available catalog columns, then delegates to :func:`estimate_fwhm`.
|
|
417
|
+
|
|
418
|
+
When a ``flux_radius`` column is present (half-light radius, as
|
|
419
|
+
produced by :func:`get_objects_sep`), it is converted to an
|
|
420
|
+
equivalent Gaussian FWHM via ``FWHM = 2 * flux_radius`` and used
|
|
421
|
+
as the primary estimator. Half-light radii are 2--3× more stable
|
|
422
|
+
than SEP's Gaussian-core FWHM (relative IQR ~0.2 vs ~0.7) and far
|
|
423
|
+
less susceptible to bias from sub-stellar contaminants, because the
|
|
424
|
+
measurement integrates flux over many pixels rather than fitting a
|
|
425
|
+
slope to a handful of core pixels.
|
|
426
|
+
|
|
427
|
+
When ``flux_radius`` is not available, the function falls back to
|
|
428
|
+
the ``fwhm`` / ``FWHM_IMAGE`` column or moment-derived FWHM,
|
|
429
|
+
and applies an ``a*b``-weighted median correction if the mode
|
|
430
|
+
appears biased (weighted median exceeds mode by > 10 %).
|
|
431
|
+
|
|
432
|
+
Parameters
|
|
433
|
+
----------
|
|
434
|
+
obj : `~astropy.table.Table` or structured ndarray
|
|
435
|
+
Detection results. Recognized columns (checked in priority order):
|
|
436
|
+
``flux_radius`` (half-light radius), ``fwhm``, ``FWHM_IMAGE``,
|
|
437
|
+
or derived from ``a``/``b`` (``A_IMAGE``/``B_IMAGE``) moments.
|
|
438
|
+
snr_min : float or None
|
|
439
|
+
Minimum S/N for candidate selection (via ``magerr``).
|
|
440
|
+
Set to None to disable.
|
|
441
|
+
max_ellipticity : float or None
|
|
442
|
+
Maximum ellipticity ``1 - b/a``. Set to None to disable.
|
|
443
|
+
If the median ellipticity of the candidates passing the S/N and
|
|
444
|
+
flag cuts exceeds this value — the signature of an elongated
|
|
445
|
+
PSF (trailed image, strong aberrations) where a fixed roundness
|
|
446
|
+
cut would reject real stars and keep mostly round contaminants —
|
|
447
|
+
the cut is replaced by an adaptive window around the population
|
|
448
|
+
median ellipticity.
|
|
449
|
+
use_flags : bool
|
|
450
|
+
If True, reject objects with nonzero flags.
|
|
451
|
+
fwhm_range : tuple of float
|
|
452
|
+
Passed through to :func:`estimate_fwhm`.
|
|
453
|
+
min_candidates : int
|
|
454
|
+
Passed through to :func:`estimate_fwhm`.
|
|
455
|
+
spatial_order : int
|
|
456
|
+
Polynomial order for a position-dependent FWHM model. ``0`` (default)
|
|
457
|
+
preserves the historical behaviour and returns a scalar. ``>=1``
|
|
458
|
+
attempts to fit a 2-D polynomial ``FWHM(x, y)`` to the per-object
|
|
459
|
+
values and returns a :class:`FWHMMap` callable. The fit uses the same
|
|
460
|
+
anti-contamination guard as the scalar path: candidates are windowed
|
|
461
|
+
to 0.5--2 times the guarded scalar estimate before the sigma-clipped
|
|
462
|
+
fit, so sub-stellar spikes and extended sources cannot pull the
|
|
463
|
+
surface. If too few candidates survive the quality cuts for the
|
|
464
|
+
requested order, the function falls back to the scalar path.
|
|
465
|
+
xy_cols : tuple of str
|
|
466
|
+
Column names used for pixel coordinates when ``spatial_order >= 1``.
|
|
467
|
+
Defaults to ``('x', 'y')`` (SEP-style). Use ``('X_IMAGE', 'Y_IMAGE')``
|
|
468
|
+
for SExtractor output. Falls back to the scalar path if either column
|
|
469
|
+
is missing.
|
|
470
|
+
image_shape : tuple of int, optional
|
|
471
|
+
``(H, W)`` image shape used to normalise pixel coordinates to roughly
|
|
472
|
+
``[-1, 1]`` when ``spatial_order >= 1``. If not provided, the min/max
|
|
473
|
+
of the candidate coordinates are used.
|
|
474
|
+
verbose : bool or callable
|
|
475
|
+
If True, log diagnostic information about the estimation.
|
|
476
|
+
|
|
477
|
+
Returns
|
|
478
|
+
-------
|
|
479
|
+
float or FWHMMap
|
|
480
|
+
Scalar FWHM in pixels when ``spatial_order == 0``. A :class:`FWHMMap`
|
|
481
|
+
callable otherwise, or a scalar on fallback. Returns ``np.nan`` if no
|
|
482
|
+
usable candidates survive.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
486
|
+
|
|
487
|
+
names = obj.dtype.names if hasattr(obj, 'dtype') else obj.colnames
|
|
488
|
+
|
|
489
|
+
# --- Extract shape columns for ab-weighting (if available) ---
|
|
490
|
+
ab = None
|
|
491
|
+
a = b = None
|
|
492
|
+
if 'a' in names and 'b' in names:
|
|
493
|
+
a = np.asarray(obj['a'], dtype=float)
|
|
494
|
+
b = np.asarray(obj['b'], dtype=float)
|
|
495
|
+
ab = a * b
|
|
496
|
+
elif 'A_IMAGE' in names and 'B_IMAGE' in names:
|
|
497
|
+
a = np.asarray(obj['A_IMAGE'], dtype=float)
|
|
498
|
+
b = np.asarray(obj['B_IMAGE'], dtype=float)
|
|
499
|
+
ab = a * b
|
|
500
|
+
|
|
501
|
+
# --- Build quality mask ---
|
|
502
|
+
good = np.ones(len(obj), dtype=bool)
|
|
503
|
+
|
|
504
|
+
if snr_min is not None:
|
|
505
|
+
if 'magerr' in names:
|
|
506
|
+
good &= np.isfinite(obj['magerr']) & (obj['magerr'] < 1.0 / snr_min)
|
|
507
|
+
good &= np.asarray(obj['magerr'], dtype=float) > 0
|
|
508
|
+
elif 'MAGERR_APER' in names:
|
|
509
|
+
magerr = np.asarray(obj['MAGERR_APER'], dtype=float)
|
|
510
|
+
if magerr.ndim > 1:
|
|
511
|
+
magerr = magerr[:, 0]
|
|
512
|
+
good &= np.isfinite(magerr) & (magerr < 1.0 / snr_min) & (magerr > 0)
|
|
513
|
+
|
|
514
|
+
if use_flags:
|
|
515
|
+
if 'flags' in names:
|
|
516
|
+
good &= np.asarray(obj['flags']) == 0
|
|
517
|
+
elif 'flag' in names:
|
|
518
|
+
good &= np.asarray(obj['flag']) == 0
|
|
519
|
+
elif 'FLAGS' in names:
|
|
520
|
+
good &= np.asarray(obj['FLAGS']) == 0
|
|
521
|
+
|
|
522
|
+
# Ellipticity handling comes after the flag cut so that flagged
|
|
523
|
+
# detections (e.g. cosmics, which are often round) do not distort the
|
|
524
|
+
# median-ellipticity regime decision below.
|
|
525
|
+
if max_ellipticity is not None and a is not None:
|
|
526
|
+
with np.errstate(invalid='ignore', divide='ignore'):
|
|
527
|
+
ell = np.where(a > 0, 1.0 - b / a, np.nan)
|
|
528
|
+
sel = good & np.isfinite(ell)
|
|
529
|
+
med_ell = np.median(ell[sel]) if np.any(sel) else np.nan
|
|
530
|
+
|
|
531
|
+
if np.isfinite(med_ell) and med_ell >= max_ellipticity:
|
|
532
|
+
# The typical quality candidate is more elongated than the
|
|
533
|
+
# roundness cut allows — elongated PSF (trailed image, strong
|
|
534
|
+
# aberrations). A fixed cut would reject real stars and keep
|
|
535
|
+
# mostly round contaminants (noise spikes, cosmics), so select
|
|
536
|
+
# a window around the population ellipticity instead.
|
|
537
|
+
mad_ell = 1.48 * np.median(np.abs(ell[sel] - med_ell))
|
|
538
|
+
margin = max(3.0 * mad_ell, 0.15)
|
|
539
|
+
lo, hi = med_ell - margin, min(med_ell + margin, 0.95)
|
|
540
|
+
log(
|
|
541
|
+
"Median candidate ellipticity %.2f exceeds max_ellipticity=%.2f "
|
|
542
|
+
"— sources appear elongated (trailed image?), keeping "
|
|
543
|
+
"ellipticity range %.2f..%.2f instead" % (med_ell, max_ellipticity, lo, hi)
|
|
544
|
+
)
|
|
545
|
+
good &= np.isfinite(ell) & (ell >= lo) & (ell <= hi)
|
|
546
|
+
else:
|
|
547
|
+
with np.errstate(invalid='ignore'):
|
|
548
|
+
good &= (a > 0) & (ell < max_ellipticity)
|
|
549
|
+
|
|
550
|
+
# --- Per-object FWHM proxies, in priority order ---
|
|
551
|
+
candidates = _fwhm_value_candidates(obj, names, a=a, b=b)
|
|
552
|
+
|
|
553
|
+
# --- Spatial (polynomial) path ---
|
|
554
|
+
# When requested, attempt a 2-D polynomial fit of FWHM(x, y) using the
|
|
555
|
+
# highest-priority FWHM proxy. Falls back to the scalar path if
|
|
556
|
+
# coordinates are missing or too few candidates survive.
|
|
557
|
+
if spatial_order >= 1:
|
|
558
|
+
xcol, ycol = xy_cols
|
|
559
|
+
if xcol in names and ycol in names:
|
|
560
|
+
if candidates:
|
|
561
|
+
sp_source, sp_values = candidates[0]
|
|
562
|
+
|
|
563
|
+
# Anti-contamination: window the candidates around the same
|
|
564
|
+
# guarded scalar estimate as used by the scalar path, so that
|
|
565
|
+
# neither sub-stellar spikes nor extended sources can pull
|
|
566
|
+
# the polynomial surface.
|
|
567
|
+
fwhm_ref, _ = _estimate_fwhm_with_guard(
|
|
568
|
+
sp_values, good, ab, fwhm_range, min_candidates, log, sp_source
|
|
569
|
+
)
|
|
570
|
+
good_sp = good
|
|
571
|
+
if np.isfinite(fwhm_ref):
|
|
572
|
+
with np.errstate(invalid='ignore'):
|
|
573
|
+
good_sp = (
|
|
574
|
+
good & (sp_values > 0.5 * fwhm_ref) & (sp_values < 2.0 * fwhm_ref)
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
fmap = _fit_spatial_fwhm(
|
|
578
|
+
sp_values,
|
|
579
|
+
np.asarray(obj[xcol], dtype=float),
|
|
580
|
+
np.asarray(obj[ycol], dtype=float),
|
|
581
|
+
good_sp,
|
|
582
|
+
order=spatial_order,
|
|
583
|
+
fwhm_range=fwhm_range,
|
|
584
|
+
image_shape=image_shape,
|
|
585
|
+
log=log,
|
|
586
|
+
)
|
|
587
|
+
if fmap is not None:
|
|
588
|
+
log(
|
|
589
|
+
"Spatial FWHM (%s, order=%d): median=%.2f, "
|
|
590
|
+
"n_used=%d" % (sp_source, spatial_order, fmap.median, fmap.n_used)
|
|
591
|
+
)
|
|
592
|
+
return fmap
|
|
593
|
+
else:
|
|
594
|
+
log(
|
|
595
|
+
"Spatial FWHM requested but coord columns %r/%r missing — "
|
|
596
|
+
"falling back to scalar" % (xcol, ycol)
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# --- Scalar path ---
|
|
600
|
+
# Try each FWHM proxy in turn; move on to the next one when the mode
|
|
601
|
+
# estimation fails (the last proxy's guarded estimate is returned as-is,
|
|
602
|
+
# possibly NaN).
|
|
603
|
+
for k, (source, values) in enumerate(candidates):
|
|
604
|
+
estimate, mode_ok = _estimate_fwhm_with_guard(
|
|
605
|
+
values, good, ab, fwhm_range, min_candidates, log, source
|
|
606
|
+
)
|
|
607
|
+
if mode_ok or k == len(candidates) - 1:
|
|
608
|
+
return estimate
|
|
609
|
+
|
|
610
|
+
log("FWHM estimation failed: no usable FWHM columns in the table")
|
|
611
|
+
return np.nan
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def get_objects_sep(
|
|
615
|
+
image,
|
|
616
|
+
header=None,
|
|
617
|
+
mask=None,
|
|
618
|
+
mask_detect=None,
|
|
619
|
+
err=None,
|
|
620
|
+
thresh=4.0,
|
|
621
|
+
aper=3.0,
|
|
622
|
+
bkgann=None,
|
|
623
|
+
r0=0.5,
|
|
624
|
+
gain=1,
|
|
625
|
+
edge=0,
|
|
626
|
+
minnthresh=2,
|
|
627
|
+
minarea=5,
|
|
628
|
+
relfluxradius=2.0,
|
|
629
|
+
wcs=None,
|
|
630
|
+
bg_size=64,
|
|
631
|
+
fwhm=False,
|
|
632
|
+
optimal=False,
|
|
633
|
+
group_sources=True,
|
|
634
|
+
group_radius_factor=1.0,
|
|
635
|
+
group_halo_factor=1.2,
|
|
636
|
+
centroid=False,
|
|
637
|
+
centroid_sigma_scale=1.0,
|
|
638
|
+
centroid_maxstep_scale=1.0,
|
|
639
|
+
centroid_maxshift_scale=0.5,
|
|
640
|
+
use_mask_large=False,
|
|
641
|
+
subtract_bg=True,
|
|
642
|
+
npix_large=100,
|
|
643
|
+
sn=10.0,
|
|
644
|
+
get_segmentation=False,
|
|
645
|
+
deblend_fwhm=0,
|
|
646
|
+
deblend_method='watershed',
|
|
647
|
+
clip_sigma=3.0,
|
|
648
|
+
clip_iters=5,
|
|
649
|
+
fwhm_spatial_order=0,
|
|
650
|
+
verbose=True,
|
|
651
|
+
**kwargs,
|
|
652
|
+
):
|
|
653
|
+
"""Object detection and aperture/optimal photometry using
|
|
654
|
+
`SEP <https://github.com/kbarbary/sep>`_.
|
|
655
|
+
|
|
656
|
+
Signature is as similar as possible to
|
|
657
|
+
:func:`~stdpipe.photometry.get_objects_sextractor`.
|
|
658
|
+
|
|
659
|
+
Algorithm: background estimation → object detection with optional
|
|
660
|
+
smoothing and deblending (watershed by default) → optional windowed
|
|
661
|
+
centroiding → edge rejection → per-object FWHM estimation → aperture
|
|
662
|
+
or optimal extraction photometry → S/N quality cut.
|
|
663
|
+
|
|
664
|
+
Detection flags are documented at
|
|
665
|
+
https://sep.readthedocs.io/en/v1.1.x/reference.html —
|
|
666
|
+
they differ from SExtractor flags.
|
|
667
|
+
|
|
668
|
+
Parameters
|
|
669
|
+
----------
|
|
670
|
+
image : numpy.ndarray
|
|
671
|
+
Input 2-D image.
|
|
672
|
+
header : astropy.io.fits.Header, optional
|
|
673
|
+
Image header.
|
|
674
|
+
mask : numpy.ndarray of bool, optional
|
|
675
|
+
Pixel mask (True = masked). Used for background estimation and
|
|
676
|
+
photometry. Objects whose detection footprint overlaps this mask
|
|
677
|
+
are flagged with 0x100 but not excluded from detection.
|
|
678
|
+
mask_detect : numpy.ndarray of bool, optional
|
|
679
|
+
Detection mask (True = exclude from detection). Combined (OR) with
|
|
680
|
+
non-finite pixel mask. Use this to exclude entire regions (e.g. bad
|
|
681
|
+
columns, satellite trails). If None, only non-finite pixels are
|
|
682
|
+
masked during detection.
|
|
683
|
+
err : numpy.ndarray, optional
|
|
684
|
+
Noise map.
|
|
685
|
+
thresh : float
|
|
686
|
+
Detection threshold in sigmas above local background.
|
|
687
|
+
aper : float
|
|
688
|
+
Aperture radius in pixels, or in FWHM units when *fwhm* is
|
|
689
|
+
provided/estimated.
|
|
690
|
+
bkgann : tuple of float, optional
|
|
691
|
+
Background annulus ``(r_in, r_out)`` in pixels (or FWHM units).
|
|
692
|
+
Uses arithmetic mean of unmasked pixels inside the annulus.
|
|
693
|
+
If None, the global background model is used.
|
|
694
|
+
r0 : float
|
|
695
|
+
Smoothing kernel sigma (pixels) for detection.
|
|
696
|
+
gain : float
|
|
697
|
+
Detector gain, e-/ADU.
|
|
698
|
+
edge : int
|
|
699
|
+
Reject objects closer to image edge than this many pixels.
|
|
700
|
+
minnthresh : int
|
|
701
|
+
Minimum number of pixels above threshold per detection.
|
|
702
|
+
minarea : int
|
|
703
|
+
Minimum number of pixels in an object footprint.
|
|
704
|
+
relfluxradius : float
|
|
705
|
+
Multiplier for rough FWHM when computing ``flux_radius`` search
|
|
706
|
+
radius (internal use).
|
|
707
|
+
wcs : astropy.wcs.WCS, optional
|
|
708
|
+
WCS for sky coordinate assignment.
|
|
709
|
+
bg_size : int
|
|
710
|
+
Background grid cell size in pixels.
|
|
711
|
+
fwhm : bool, float or callable
|
|
712
|
+
FWHM handling. ``False`` (default) — per-object FWHM for output
|
|
713
|
+
only. ``True`` — estimate global FWHM and scale *aper*/*bkgann*
|
|
714
|
+
(interpreted as FWHM units). Numeric — use this value directly
|
|
715
|
+
and scale *aper*/*bkgann*. A callable (e.g. :class:`FWHMMap`)
|
|
716
|
+
is evaluated per source: *aper*/*bkgann* and the optimal PSF
|
|
717
|
+
width become position-dependent.
|
|
718
|
+
optimal : bool
|
|
719
|
+
Use optimal extraction (``sep.sum_circle_optimal``) instead of
|
|
720
|
+
aperture photometry. Requires FWHM. SEP 1.4+ only.
|
|
721
|
+
group_sources : bool
|
|
722
|
+
Grouped optimal extraction for overlapping sources.
|
|
723
|
+
Default True (recommended). SEP 1.4+ only.
|
|
724
|
+
group_radius_factor : float
|
|
725
|
+
Connectivity scale for grouped optimal extraction. The default 1.0
|
|
726
|
+
groups sources whose apertures overlap.
|
|
727
|
+
group_halo_factor : float or None
|
|
728
|
+
Local context halo scale for grouped optimal extraction. The default
|
|
729
|
+
1.2 keeps a modest expanded solve context without widening source
|
|
730
|
+
connectivity. ``None`` lets SEP use ``group_radius_factor``.
|
|
731
|
+
centroid : bool
|
|
732
|
+
Refine positions via ``sep.winpos``. Default False (windowed
|
|
733
|
+
positions can be biased in crowded fields). With SEP 1.4+, uses
|
|
734
|
+
``maxstep = 0.2 * FWHM``.
|
|
735
|
+
centroid_sigma_scale : float
|
|
736
|
+
Multiplicative scale applied to the Gaussian sigma passed to
|
|
737
|
+
``sep.winpos``. The default 1.0 keeps the historical behavior
|
|
738
|
+
``sigma = FWHM / 2.355``. Values below 1 use a tighter window;
|
|
739
|
+
values above 1 use a broader window.
|
|
740
|
+
centroid_maxstep_scale : float
|
|
741
|
+
Multiplicative scale applied to the SEP 1.4+ ``maxstep`` limit
|
|
742
|
+
used by ``sep.winpos``. The default 1.0 keeps the historical
|
|
743
|
+
cap ``maxstep = 0.2 * FWHM``. Values below 1 restrict the allowed
|
|
744
|
+
centroid motion more strongly; values above 1 loosen it.
|
|
745
|
+
centroid_maxshift_scale : float
|
|
746
|
+
Multiplicative scale applied to the SEP 1.4+ ``maxshift`` cap on
|
|
747
|
+
the *total* centroid drift across all winpos iterations (in units
|
|
748
|
+
of FWHM). The default 0.5 means a centroid can move at most
|
|
749
|
+
``0.5 * FWHM`` from its initial position, complementing the
|
|
750
|
+
per-iteration ``maxstep`` cap. Set to a large value to effectively
|
|
751
|
+
disable. Ignored when SEP is older than 1.4 or when FWHM is not
|
|
752
|
+
available.
|
|
753
|
+
use_mask_large : bool
|
|
754
|
+
Filter out objects with footprints larger than *npix_large*.
|
|
755
|
+
npix_large : int
|
|
756
|
+
Pixel-count threshold for large-object rejection.
|
|
757
|
+
subtract_bg : bool
|
|
758
|
+
Subtract background before detection (default True).
|
|
759
|
+
sn : float
|
|
760
|
+
Minimum S/N (``magerr < 1/sn``) for output.
|
|
761
|
+
get_segmentation : bool
|
|
762
|
+
Also return the segmentation map and add ``seg_id`` column.
|
|
763
|
+
deblend_fwhm : float
|
|
764
|
+
Fixed Gaussian FWHM (pixels) for deterministic deblending.
|
|
765
|
+
0 = adaptive shapes with stochastic assignment. SEP 1.4+ only.
|
|
766
|
+
deblend_method : {'watershed', 'threshold'}
|
|
767
|
+
Deblending algorithm. SEP 1.4+ only.
|
|
768
|
+
clip_sigma : float
|
|
769
|
+
Sigma-clipping threshold for annulus background. SEP 1.4+ only.
|
|
770
|
+
clip_iters : int
|
|
771
|
+
Max sigma-clipping iterations for annulus background.
|
|
772
|
+
0 = disable. SEP 1.4+ only.
|
|
773
|
+
fwhm_spatial_order : int
|
|
774
|
+
When FWHM is auto-estimated (``fwhm=True`` or via the optimal path),
|
|
775
|
+
fit a 2-D polynomial ``FWHM(x, y)`` of this order and apply it
|
|
776
|
+
per source. ``0`` (default) keeps the historical scalar behaviour.
|
|
777
|
+
Per-source values are used for aperture/background-annulus scaling
|
|
778
|
+
and for the optimal-extraction PSF width; the windowed centroider
|
|
779
|
+
still uses the scalar median. Ignored when a numeric ``fwhm`` is
|
|
780
|
+
supplied directly. See :class:`FWHMMap`.
|
|
781
|
+
verbose : bool or callable
|
|
782
|
+
Print progress messages.
|
|
783
|
+
|
|
784
|
+
Returns
|
|
785
|
+
-------
|
|
786
|
+
astropy.table.Table or tuple
|
|
787
|
+
Table of detected objects. If *get_segmentation* is True, returns
|
|
788
|
+
``(table, segmentation_map)``.
|
|
789
|
+
|
|
790
|
+
**Columns:** x, y, xerr, yerr, flux, fluxerr, mag, magerr,
|
|
791
|
+
flags, ra, dec, bg, fwhm, a, b, theta, and optionally seg_id.
|
|
792
|
+
|
|
793
|
+
**Metadata:** aper, bkgann, optimal, group_sources, fwhm_phot.
|
|
794
|
+
|
|
795
|
+
Notes
|
|
796
|
+
-----
|
|
797
|
+
- FWHM estimation prefers SEP's built-in method (ported from
|
|
798
|
+
SExtractor). Falls back to 2nd moments for older SEP versions.
|
|
799
|
+
- Optimal extraction assumes a Gaussian PSF.
|
|
800
|
+
- Grouped extraction fits nearby sources simultaneously — dramatically
|
|
801
|
+
improves accuracy for close pairs.
|
|
802
|
+
- Watershed deblending often gives better completeness for pairs
|
|
803
|
+
closer than ~2 FWHM.
|
|
804
|
+
"""
|
|
805
|
+
|
|
806
|
+
# Simple Wrapper around print for logging in verbose mode only
|
|
807
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
808
|
+
|
|
809
|
+
if r0 > 0.0:
|
|
810
|
+
kernel = make_kernel(r0)
|
|
811
|
+
else:
|
|
812
|
+
kernel = None
|
|
813
|
+
|
|
814
|
+
log("Preparing background mask")
|
|
815
|
+
|
|
816
|
+
if mask is None:
|
|
817
|
+
mask = np.zeros_like(image, dtype=bool)
|
|
818
|
+
|
|
819
|
+
mask_bg = np.zeros_like(mask)
|
|
820
|
+
mask_segm = np.zeros_like(mask)
|
|
821
|
+
|
|
822
|
+
# Detection mask: non-finite pixels plus optional user-provided detection
|
|
823
|
+
# mask. The main user mask is not passed to sep.extract() so that bright
|
|
824
|
+
# objects with partially masked cores (e.g. saturated pixels) are detected
|
|
825
|
+
# and deblended as single objects rather than being fragmented into
|
|
826
|
+
# ring-shaped artifacts. The user mask is still used for background
|
|
827
|
+
# estimation and photometry. Use mask_detect to exclude entire regions
|
|
828
|
+
# (e.g. bad columns, satellite trails) from detection.
|
|
829
|
+
mask_det = ~np.isfinite(image)
|
|
830
|
+
if mask_detect is not None:
|
|
831
|
+
mask_det = mask_det | mask_detect
|
|
832
|
+
|
|
833
|
+
log("Building background map")
|
|
834
|
+
|
|
835
|
+
bg = sep.Background(image, mask=mask | mask_bg, bw=bg_size, bh=bg_size)
|
|
836
|
+
if subtract_bg:
|
|
837
|
+
image1 = image - bg.back()
|
|
838
|
+
else:
|
|
839
|
+
image1 = image.copy()
|
|
840
|
+
|
|
841
|
+
if err is None:
|
|
842
|
+
err = bg.rms()
|
|
843
|
+
err[~np.isfinite(err)] = 1e30
|
|
844
|
+
err[err == 0] = 1e30
|
|
845
|
+
|
|
846
|
+
sep.set_extract_pixstack(image.shape[0] * image.shape[1])
|
|
847
|
+
|
|
848
|
+
if use_mask_large:
|
|
849
|
+
# Mask regions around huge objects as they are most probably corrupted by saturation and blooming
|
|
850
|
+
log("Extracting initial objects")
|
|
851
|
+
|
|
852
|
+
# Minimal extraction, just to get large objects
|
|
853
|
+
obj0, segm = sep.extract(
|
|
854
|
+
image1,
|
|
855
|
+
err=err,
|
|
856
|
+
thresh=thresh,
|
|
857
|
+
minarea=minarea,
|
|
858
|
+
mask=mask_det | mask_bg,
|
|
859
|
+
filter_kernel=kernel,
|
|
860
|
+
segmentation_map=True,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
log("Dilating large objects")
|
|
864
|
+
|
|
865
|
+
mask_segm = np.isin(
|
|
866
|
+
segm, [_ + 1 for _, npix in enumerate(obj0['npix']) if npix > npix_large]
|
|
867
|
+
)
|
|
868
|
+
mask_segm = dilate(mask_segm, np.ones([10, 10]))
|
|
869
|
+
|
|
870
|
+
log("Extracting final objects")
|
|
871
|
+
|
|
872
|
+
extract_kwargs = {
|
|
873
|
+
'err': err,
|
|
874
|
+
'thresh': thresh,
|
|
875
|
+
'minarea': minarea,
|
|
876
|
+
'mask': mask_det | mask_bg | mask_segm,
|
|
877
|
+
'filter_kernel': kernel,
|
|
878
|
+
'segmentation_map': True,
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
# Add deblending parameters if available (SEP 1.4+)
|
|
882
|
+
if _HAS_SEP_OPTIMAL:
|
|
883
|
+
extract_kwargs['deblend_fwhm'] = deblend_fwhm
|
|
884
|
+
extract_kwargs['deblend_method'] = deblend_method
|
|
885
|
+
|
|
886
|
+
# Merge with any additional kwargs
|
|
887
|
+
extract_kwargs.update(kwargs)
|
|
888
|
+
|
|
889
|
+
obj0, segm = sep.extract(image1, **extract_kwargs)
|
|
890
|
+
|
|
891
|
+
# Flag objects whose footprints overlap the user mask.
|
|
892
|
+
# 0x100 = object has masked pixels within its detection footprint.
|
|
893
|
+
if mask.any():
|
|
894
|
+
# For each segmentation label, check if any of its pixels are masked
|
|
895
|
+
# Use fast vectorized approach: find unique labels that overlap mask
|
|
896
|
+
masked_labels = np.unique(segm[mask & (segm > 0)])
|
|
897
|
+
# seg_id for object i is i+1 (1-based)
|
|
898
|
+
obj_seg_ids = np.arange(1, len(obj0) + 1)
|
|
899
|
+
has_masked = np.isin(obj_seg_ids, masked_labels)
|
|
900
|
+
obj0['flag'][has_masked] |= 0x100
|
|
901
|
+
|
|
902
|
+
# Compute half-light radius for all extracted objects. This is done
|
|
903
|
+
# early so that estimate_fwhm_from_objects can use it for FWHM
|
|
904
|
+
# estimation (flux_radius is more stable than SEP's Gaussian-core FWHM).
|
|
905
|
+
_x0 = obj0['x']
|
|
906
|
+
_y0 = obj0['y']
|
|
907
|
+
_a0 = np.maximum(obj0['a'], 0.5)
|
|
908
|
+
_b0 = np.maximum(obj0['b'], 0.5)
|
|
909
|
+
_theta0 = obj0['theta']
|
|
910
|
+
_kronrad0, _ = sep.kron_radius(
|
|
911
|
+
image1,
|
|
912
|
+
_x0,
|
|
913
|
+
_y0,
|
|
914
|
+
_a0,
|
|
915
|
+
_b0,
|
|
916
|
+
_theta0,
|
|
917
|
+
6.0,
|
|
918
|
+
mask=mask | mask_bg | mask_segm,
|
|
919
|
+
)
|
|
920
|
+
_kronrad0 = np.maximum(_kronrad0, 1.0)
|
|
921
|
+
_rmax0 = np.clip(relfluxradius * _kronrad0 * np.maximum(_a0, _b0), 3.0, 50.0)
|
|
922
|
+
_flux_auto0, _, _ = sep.sum_circle(
|
|
923
|
+
image1,
|
|
924
|
+
_x0,
|
|
925
|
+
_y0,
|
|
926
|
+
_rmax0,
|
|
927
|
+
mask=mask | mask_bg | mask_segm,
|
|
928
|
+
)
|
|
929
|
+
_flux_radius0, _ = sep.flux_radius(
|
|
930
|
+
image1,
|
|
931
|
+
_x0,
|
|
932
|
+
_y0,
|
|
933
|
+
_rmax0,
|
|
934
|
+
0.5,
|
|
935
|
+
normflux=_flux_auto0,
|
|
936
|
+
subpix=5,
|
|
937
|
+
)
|
|
938
|
+
_flux_radius0 = np.asarray(_flux_radius0, dtype=float)
|
|
939
|
+
|
|
940
|
+
# Build a minimal Table from obj0 so estimate_fwhm_from_objects
|
|
941
|
+
# can see flux_radius alongside the standard SEP columns.
|
|
942
|
+
_obj0_for_fwhm = Table(
|
|
943
|
+
{
|
|
944
|
+
'fwhm': obj0['fwhm']
|
|
945
|
+
if 'fwhm' in obj0.dtype.names
|
|
946
|
+
else 2.0 * np.sqrt(np.log(2) * (obj0['a'] ** 2 + obj0['b'] ** 2)),
|
|
947
|
+
'flux_radius': _flux_radius0,
|
|
948
|
+
'a': obj0['a'],
|
|
949
|
+
'b': obj0['b'],
|
|
950
|
+
'x': obj0['x'],
|
|
951
|
+
'y': obj0['y'],
|
|
952
|
+
'flags': obj0['flag'],
|
|
953
|
+
}
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
# Handle FWHM parameter. ``fwhm_value`` ends up being either a scalar
|
|
957
|
+
# float or a position-dependent callable (e.g. :class:`FWHMMap`) when
|
|
958
|
+
# ``fwhm_spatial_order >= 1`` or a callable ``fwhm`` is supplied.
|
|
959
|
+
fwhm_value = None
|
|
960
|
+
scale_with_fwhm = False
|
|
961
|
+
|
|
962
|
+
if isinstance(fwhm, (int, float)) and not isinstance(fwhm, bool) and fwhm > 0:
|
|
963
|
+
# Numeric FWHM value provided directly
|
|
964
|
+
fwhm_value = float(fwhm)
|
|
965
|
+
scale_with_fwhm = True
|
|
966
|
+
log("Using provided FWHM = %.2g" % fwhm_value)
|
|
967
|
+
elif _is_callable_fwhm(fwhm):
|
|
968
|
+
# User-supplied position-dependent FWHM model (e.g. FWHMMap)
|
|
969
|
+
fwhm_value = fwhm
|
|
970
|
+
scale_with_fwhm = True
|
|
971
|
+
log(
|
|
972
|
+
"Using provided spatial FWHM model (median=%.2g)"
|
|
973
|
+
% _fwhm_median(fwhm, image.shape)
|
|
974
|
+
)
|
|
975
|
+
elif fwhm is True or (optimal and fwhm is False):
|
|
976
|
+
# Estimate FWHM from detected objects using robust mode estimator
|
|
977
|
+
# (either explicitly requested or needed for optimal extraction)
|
|
978
|
+
fwhm_value = estimate_fwhm_from_objects(
|
|
979
|
+
_obj0_for_fwhm,
|
|
980
|
+
snr_min=None,
|
|
981
|
+
use_flags=True,
|
|
982
|
+
spatial_order=fwhm_spatial_order,
|
|
983
|
+
image_shape=image.shape,
|
|
984
|
+
verbose=verbose,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
if not _is_callable_fwhm(fwhm_value) and not np.isfinite(fwhm_value):
|
|
988
|
+
# Fall back to plain median of moment-based FWHM for unflagged objects
|
|
989
|
+
idx_fwhm = obj0['flag'] == 0
|
|
990
|
+
if 'fwhm' in obj0.dtype.names:
|
|
991
|
+
fwhm_value = np.nanmedian(obj0['fwhm'][idx_fwhm])
|
|
992
|
+
else:
|
|
993
|
+
fwhm_value = np.nanmedian(
|
|
994
|
+
2.0 * np.sqrt(np.log(2) * (obj0['a'][idx_fwhm] ** 2 + obj0['b'][idx_fwhm] ** 2))
|
|
995
|
+
)
|
|
996
|
+
log("Robust FWHM estimation failed, falling back to median")
|
|
997
|
+
|
|
998
|
+
if fwhm is True:
|
|
999
|
+
# Scale aperture/annulus when explicitly requested (fwhm=True)
|
|
1000
|
+
scale_with_fwhm = True
|
|
1001
|
+
|
|
1002
|
+
if isinstance(fwhm_value, FWHMMap):
|
|
1003
|
+
log(
|
|
1004
|
+
"Estimated spatial FWHM (order=%d, median=%.2g)"
|
|
1005
|
+
% (fwhm_value.order, fwhm_value.median)
|
|
1006
|
+
)
|
|
1007
|
+
else:
|
|
1008
|
+
log("Estimated FWHM = %.2g" % fwhm_value)
|
|
1009
|
+
|
|
1010
|
+
# Stash the FWHM-units base so we can evaluate per-source apertures at
|
|
1011
|
+
# the photometry call sites when fwhm_value is position-dependent.
|
|
1012
|
+
aper_base = aper
|
|
1013
|
+
bkgann_base = bkgann
|
|
1014
|
+
fwhm_is_spatial = _is_callable_fwhm(fwhm_value)
|
|
1015
|
+
# Scalar summary of the FWHM model for the scalar-only call sites below
|
|
1016
|
+
# (logging, windowed centroiding, table metadata).
|
|
1017
|
+
fwhm_median = _fwhm_median(fwhm_value, image.shape) if fwhm_value is not None else None
|
|
1018
|
+
|
|
1019
|
+
# Scale aperture and background annulus with FWHM if requested
|
|
1020
|
+
if scale_with_fwhm and fwhm_value is not None:
|
|
1021
|
+
if fwhm_is_spatial:
|
|
1022
|
+
log("Scaling aperture and background annulus per source via spatial FWHM")
|
|
1023
|
+
log(" Aperture (median): %.2g pixels" % (aper * fwhm_median))
|
|
1024
|
+
if bkgann is not None:
|
|
1025
|
+
log(
|
|
1026
|
+
" Background annulus (median): (%.2g, %.2g) pixels"
|
|
1027
|
+
% (bkgann[0] * fwhm_median, bkgann[1] * fwhm_median)
|
|
1028
|
+
)
|
|
1029
|
+
else:
|
|
1030
|
+
log("Scaling aperture and background annulus with FWHM")
|
|
1031
|
+
aper *= fwhm_value
|
|
1032
|
+
if bkgann is not None:
|
|
1033
|
+
bkgann = (bkgann[0] * fwhm_value, bkgann[1] * fwhm_value)
|
|
1034
|
+
log(" Aperture: %.2g pixels" % aper)
|
|
1035
|
+
if bkgann is not None:
|
|
1036
|
+
log(" Background annulus: (%.2g, %.2g) pixels" % bkgann)
|
|
1037
|
+
|
|
1038
|
+
# Windowed positional parameters - use if requested
|
|
1039
|
+
if centroid:
|
|
1040
|
+
log("Refining positions using windowed centroiding")
|
|
1041
|
+
# sep.winpos takes a scalar sigma/maxstep, so use the median summary
|
|
1042
|
+
# of the spatial model when one is in play.
|
|
1043
|
+
fwhm_scalar = fwhm_median
|
|
1044
|
+
# Constrain winpos to each source's own segmentation footprint.
|
|
1045
|
+
# Without this, sep.winpos is segmentation-blind and deblended
|
|
1046
|
+
# sub-objects sharing a flux peak (e.g. saturated cores split into
|
|
1047
|
+
# fragments) all converge to the same point. Negative seg_id tells
|
|
1048
|
+
# SEP to mask every pixel except those belonging to that source.
|
|
1049
|
+
winpos_kwargs = {
|
|
1050
|
+
'mask': mask | mask_bg | mask_segm,
|
|
1051
|
+
'segmap': segm if segm.dtype == np.int32 else segm.astype(np.int32),
|
|
1052
|
+
'seg_id': -np.arange(1, len(obj0) + 1, dtype=np.int32),
|
|
1053
|
+
}
|
|
1054
|
+
if _HAS_SEP_OPTIMAL:
|
|
1055
|
+
# SEP 1.4+ has maxstep parameter to cap position shift per iteration
|
|
1056
|
+
# Set to 0.2 * FWHM to avoid excessive drift in degenerate cases
|
|
1057
|
+
if fwhm_scalar is not None:
|
|
1058
|
+
maxstep = 0.2 * fwhm_scalar * float(centroid_maxstep_scale)
|
|
1059
|
+
else:
|
|
1060
|
+
# Default to 1.0 pixel if FWHM not available
|
|
1061
|
+
maxstep = 1.0 * float(centroid_maxstep_scale)
|
|
1062
|
+
if not np.isfinite(maxstep) or maxstep <= 0:
|
|
1063
|
+
raise ValueError("centroid_maxstep_scale should produce a positive finite maxstep")
|
|
1064
|
+
winpos_kwargs['maxstep'] = maxstep
|
|
1065
|
+
|
|
1066
|
+
# SEP 1.4+ also has maxshift parameter to cap the *total* drift
|
|
1067
|
+
# across all winpos iterations. Belt-and-braces alongside the
|
|
1068
|
+
# segmap restriction: bounds runaway centroids even when a source
|
|
1069
|
+
# spans a large segment (e.g. saturated cores with bloom trails).
|
|
1070
|
+
if fwhm_scalar is not None:
|
|
1071
|
+
maxshift = fwhm_scalar * float(centroid_maxshift_scale)
|
|
1072
|
+
if not np.isfinite(maxshift) or maxshift <= 0:
|
|
1073
|
+
raise ValueError("centroid_maxshift_scale should produce a positive finite maxshift")
|
|
1074
|
+
winpos_kwargs['maxshift'] = maxshift
|
|
1075
|
+
|
|
1076
|
+
sig = fwhm_scalar / 2.355 if fwhm_scalar is not None else 0.5
|
|
1077
|
+
sig *= float(centroid_sigma_scale)
|
|
1078
|
+
if not np.isfinite(sig) or sig <= 0:
|
|
1079
|
+
raise ValueError("centroid_sigma_scale should produce a positive finite sigma")
|
|
1080
|
+
xwin, ywin, flag_win = sep.winpos(image1, obj0['x'], obj0['y'], sig, **winpos_kwargs)
|
|
1081
|
+
# Drop sep.APER_HASMASKED: our segmap restriction (seg_id=-N) masks every
|
|
1082
|
+
# pixel outside each source's footprint, so the winpos aperture always
|
|
1083
|
+
# overlaps masked pixels by construction. Keep TRUNC / ALLMASKED /
|
|
1084
|
+
# NONPOSITIVE — those still convey real centroiding-quality info.
|
|
1085
|
+
flag_win &= ~np.int_(sep.APER_HASMASKED)
|
|
1086
|
+
else:
|
|
1087
|
+
# Use initial positions from extraction
|
|
1088
|
+
xwin, ywin = obj0['x'], obj0['y']
|
|
1089
|
+
flag_win = np.zeros(len(obj0), dtype=int)
|
|
1090
|
+
|
|
1091
|
+
# Filter out objects too close to frame edges
|
|
1092
|
+
idx = (
|
|
1093
|
+
(np.round(xwin) > edge)
|
|
1094
|
+
& (np.round(ywin) > edge)
|
|
1095
|
+
& (np.round(xwin) < image.shape[1] - edge)
|
|
1096
|
+
& (np.round(ywin) < image.shape[0] - edge)
|
|
1097
|
+
) # & (obj0['flag'] == 0)
|
|
1098
|
+
|
|
1099
|
+
if minnthresh:
|
|
1100
|
+
idx &= obj0['tnpix'] >= minnthresh
|
|
1101
|
+
|
|
1102
|
+
log("Measuring final objects")
|
|
1103
|
+
|
|
1104
|
+
# Build per-source aperture / background annulus / PSF-width arrays when
|
|
1105
|
+
# a spatial FWHM model is in use. Aperture / bkgann only vary per source
|
|
1106
|
+
# when the user asked for FWHM-unit scaling; the optimal PSF width follows
|
|
1107
|
+
# the spatial model whenever one is available.
|
|
1108
|
+
if fwhm_is_spatial:
|
|
1109
|
+
_fwhm_pos = np.asarray(fwhm_value(xwin[idx], ywin[idx]), dtype=float)
|
|
1110
|
+
if _fwhm_pos.ndim == 0:
|
|
1111
|
+
_fwhm_pos = np.full(int(np.sum(idx)), float(_fwhm_pos))
|
|
1112
|
+
else:
|
|
1113
|
+
_fwhm_pos = None
|
|
1114
|
+
|
|
1115
|
+
if scale_with_fwhm and fwhm_is_spatial:
|
|
1116
|
+
aper_phot = aper_base * _fwhm_pos
|
|
1117
|
+
if bkgann_base is not None:
|
|
1118
|
+
bkgann_phot = (
|
|
1119
|
+
bkgann_base[0] * _fwhm_pos,
|
|
1120
|
+
bkgann_base[1] * _fwhm_pos,
|
|
1121
|
+
)
|
|
1122
|
+
else:
|
|
1123
|
+
bkgann_phot = None
|
|
1124
|
+
else:
|
|
1125
|
+
aper_phot = aper
|
|
1126
|
+
bkgann_phot = bkgann
|
|
1127
|
+
|
|
1128
|
+
if fwhm_is_spatial:
|
|
1129
|
+
fwhm_phot_arg = _fwhm_pos
|
|
1130
|
+
elif fwhm_value is not None:
|
|
1131
|
+
fwhm_phot_arg = float(fwhm_value)
|
|
1132
|
+
else:
|
|
1133
|
+
fwhm_phot_arg = None
|
|
1134
|
+
|
|
1135
|
+
# Choose photometry method
|
|
1136
|
+
if optimal and _HAS_SEP_OPTIMAL:
|
|
1137
|
+
if group_sources:
|
|
1138
|
+
log(
|
|
1139
|
+
"Using grouped optimal extraction "
|
|
1140
|
+
"(radius_factor=%.2g, halo_factor=%s)"
|
|
1141
|
+
% (
|
|
1142
|
+
group_radius_factor,
|
|
1143
|
+
("SEP default" if group_halo_factor is None else "%.2g" % group_halo_factor),
|
|
1144
|
+
)
|
|
1145
|
+
)
|
|
1146
|
+
else:
|
|
1147
|
+
log("Using optimal extraction")
|
|
1148
|
+
flux, fluxerr, flag = sep.sum_circle_optimal(
|
|
1149
|
+
image1,
|
|
1150
|
+
xwin[idx],
|
|
1151
|
+
ywin[idx],
|
|
1152
|
+
aper_phot,
|
|
1153
|
+
fwhm=fwhm_phot_arg,
|
|
1154
|
+
err=err,
|
|
1155
|
+
gain=gain,
|
|
1156
|
+
mask=mask | mask_bg | mask_segm,
|
|
1157
|
+
bkgann=bkgann_phot,
|
|
1158
|
+
grouped=group_sources,
|
|
1159
|
+
group_radius_factor=group_radius_factor,
|
|
1160
|
+
group_halo_factor=group_halo_factor,
|
|
1161
|
+
clip_sigma=clip_sigma,
|
|
1162
|
+
clip_iters=clip_iters,
|
|
1163
|
+
)
|
|
1164
|
+
else:
|
|
1165
|
+
if optimal and not _HAS_SEP_OPTIMAL:
|
|
1166
|
+
log(
|
|
1167
|
+
"WARNING: Optimal extraction requested but SEP 1.4+ not available, falling back to aperture photometry"
|
|
1168
|
+
)
|
|
1169
|
+
flux, fluxerr, flag = sep.sum_circle(
|
|
1170
|
+
image1,
|
|
1171
|
+
xwin[idx],
|
|
1172
|
+
ywin[idx],
|
|
1173
|
+
aper_phot,
|
|
1174
|
+
err=err,
|
|
1175
|
+
gain=gain,
|
|
1176
|
+
mask=mask | mask_bg | mask_segm,
|
|
1177
|
+
bkgann=bkgann_phot,
|
|
1178
|
+
clip_sigma=clip_sigma,
|
|
1179
|
+
clip_iters=clip_iters,
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
# For debug purposes, let's make also the same aperture photometry on the background map
|
|
1183
|
+
bgflux, bgfluxerr, bgflag = sep.sum_circle(
|
|
1184
|
+
bg.back(),
|
|
1185
|
+
xwin[idx],
|
|
1186
|
+
ywin[idx],
|
|
1187
|
+
aper_phot,
|
|
1188
|
+
err=bg.rms(),
|
|
1189
|
+
gain=gain,
|
|
1190
|
+
mask=mask | mask_bg | mask_segm,
|
|
1191
|
+
clip_sigma=clip_sigma,
|
|
1192
|
+
clip_iters=clip_iters,
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
bgnorm = bgflux / np.pi / np.asarray(aper_phot) ** 2
|
|
1196
|
+
|
|
1197
|
+
# Fluxes to magnitudes
|
|
1198
|
+
mag, magerr = np.zeros_like(flux), np.zeros_like(flux)
|
|
1199
|
+
mag[flux > 0] = -2.5 * np.log10(flux[flux > 0])
|
|
1200
|
+
# magerr[flux>0] = 2.5*np.log10(1.0 + fluxerr[flux>0]/flux[flux>0])
|
|
1201
|
+
magerr[flux > 0] = 2.5 / np.log(10) * fluxerr[flux > 0] / flux[flux > 0]
|
|
1202
|
+
|
|
1203
|
+
# FWHM estimation - prefer SEP's built-in FWHM (ported from SExtractor)
|
|
1204
|
+
# Fall back to 2nd moments for older SEP versions
|
|
1205
|
+
if 'fwhm' in obj0.dtype.names:
|
|
1206
|
+
# Use SEP's built-in FWHM (nearly identical to SExtractor)
|
|
1207
|
+
fwhm = obj0['fwhm'][idx]
|
|
1208
|
+
else:
|
|
1209
|
+
# Fall back to 2nd moments: FWHM = 2 * sqrt(ln(2) * (a^2 + b^2))
|
|
1210
|
+
# This is exact for Gaussian PSF, approximate for others
|
|
1211
|
+
# More robust than flux_radius (5-27x fewer outliers) but less accurate than SEP built-in
|
|
1212
|
+
fwhm = 2.0 * np.sqrt(np.log(2) * (obj0['a'][idx] ** 2 + obj0['b'][idx] ** 2))
|
|
1213
|
+
|
|
1214
|
+
# Reuse the half-light radius already computed for all objects
|
|
1215
|
+
_flux_radius = _flux_radius0[idx]
|
|
1216
|
+
|
|
1217
|
+
flag |= obj0['flag'][idx] | flag_win[idx]
|
|
1218
|
+
|
|
1219
|
+
# Quality cuts
|
|
1220
|
+
fidx = (flux > 0) & (magerr < 1.0 / sn)
|
|
1221
|
+
|
|
1222
|
+
if wcs is None and header is not None:
|
|
1223
|
+
# If header is provided, we may build WCS from it
|
|
1224
|
+
wcs = WCS(header)
|
|
1225
|
+
|
|
1226
|
+
if wcs is not None:
|
|
1227
|
+
# If WCS is provided we may convert x,y to ra,dec
|
|
1228
|
+
ra, dec = wcs.all_pix2world(obj0['x'][idx], obj0['y'][idx], 0)
|
|
1229
|
+
else:
|
|
1230
|
+
ra, dec = np.zeros_like(obj0['x'][idx]), np.zeros_like(obj0['y'][idx])
|
|
1231
|
+
|
|
1232
|
+
if verbose:
|
|
1233
|
+
log("All done")
|
|
1234
|
+
|
|
1235
|
+
obj = Table(
|
|
1236
|
+
{
|
|
1237
|
+
'x': xwin[idx][fidx],
|
|
1238
|
+
'y': ywin[idx][fidx],
|
|
1239
|
+
'xerr': np.sqrt(obj0['errx2'][idx][fidx]),
|
|
1240
|
+
'yerr': np.sqrt(obj0['erry2'][idx][fidx]),
|
|
1241
|
+
'flux': flux[fidx],
|
|
1242
|
+
'fluxerr': fluxerr[fidx],
|
|
1243
|
+
'mag': mag[fidx],
|
|
1244
|
+
'magerr': magerr[fidx],
|
|
1245
|
+
'flags': obj0['flag'][idx][fidx] | flag[fidx],
|
|
1246
|
+
'ra': ra[fidx],
|
|
1247
|
+
'dec': dec[fidx],
|
|
1248
|
+
'bg': bgnorm[fidx],
|
|
1249
|
+
'fwhm': fwhm[fidx],
|
|
1250
|
+
'flux_radius': _flux_radius[fidx],
|
|
1251
|
+
'a': obj0['a'][idx][fidx],
|
|
1252
|
+
'b': obj0['b'][idx][fidx],
|
|
1253
|
+
'theta': obj0['theta'][idx][fidx],
|
|
1254
|
+
}
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
if scale_with_fwhm and fwhm_is_spatial:
|
|
1258
|
+
obj.meta['aper'] = aper_base * fwhm_median
|
|
1259
|
+
if bkgann_base is not None:
|
|
1260
|
+
obj.meta['bkgann'] = (
|
|
1261
|
+
bkgann_base[0] * fwhm_median,
|
|
1262
|
+
bkgann_base[1] * fwhm_median,
|
|
1263
|
+
)
|
|
1264
|
+
else:
|
|
1265
|
+
obj.meta['bkgann'] = None
|
|
1266
|
+
else:
|
|
1267
|
+
obj.meta['aper'] = aper
|
|
1268
|
+
obj.meta['bkgann'] = bkgann
|
|
1269
|
+
obj.meta['optimal'] = optimal and _HAS_SEP_OPTIMAL
|
|
1270
|
+
obj.meta['group_sources'] = group_sources and optimal and _HAS_SEP_OPTIMAL
|
|
1271
|
+
if obj.meta['group_sources']:
|
|
1272
|
+
obj.meta['group_radius_factor'] = group_radius_factor
|
|
1273
|
+
obj.meta['group_halo_factor'] = group_halo_factor
|
|
1274
|
+
if fwhm_value is not None:
|
|
1275
|
+
# Only the scalar summary goes into meta — a live model object would
|
|
1276
|
+
# break table serialization (FITS/parquet writers).
|
|
1277
|
+
obj.meta['fwhm_phot'] = fwhm_median if fwhm_is_spatial else fwhm_value
|
|
1278
|
+
|
|
1279
|
+
obj.sort('flux', reverse=True)
|
|
1280
|
+
|
|
1281
|
+
if get_segmentation:
|
|
1282
|
+
# Segmentation map ID (sequential object number + 1, 1-based indexing)
|
|
1283
|
+
seg_id = np.arange(len(obj0['x'])) + 1
|
|
1284
|
+
obj['seg_id'] = seg_id[idx][fidx]
|
|
1285
|
+
|
|
1286
|
+
return obj, segm
|
|
1287
|
+
else:
|
|
1288
|
+
return obj
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def get_objects_sextractor(
|
|
1292
|
+
image,
|
|
1293
|
+
header=None,
|
|
1294
|
+
mask=None,
|
|
1295
|
+
mask_detect=None,
|
|
1296
|
+
err=None,
|
|
1297
|
+
thresh=2.0,
|
|
1298
|
+
aper=3.0,
|
|
1299
|
+
r0=0.0,
|
|
1300
|
+
gain=1,
|
|
1301
|
+
edge=0,
|
|
1302
|
+
minarea=5,
|
|
1303
|
+
wcs=None,
|
|
1304
|
+
sn=3.0,
|
|
1305
|
+
bg_size=None,
|
|
1306
|
+
sort=True,
|
|
1307
|
+
reject_negative=True,
|
|
1308
|
+
mask_to_nans=True,
|
|
1309
|
+
checkimages=[],
|
|
1310
|
+
extra_params=[],
|
|
1311
|
+
extra={},
|
|
1312
|
+
psf=None,
|
|
1313
|
+
catfile=None,
|
|
1314
|
+
_workdir=None,
|
|
1315
|
+
_tmpdir=None,
|
|
1316
|
+
_exe=None,
|
|
1317
|
+
verbose=False,
|
|
1318
|
+
):
|
|
1319
|
+
"""Thin wrapper around the SExtractor binary.
|
|
1320
|
+
|
|
1321
|
+
Processes the image with optional mask and noise map and returns the
|
|
1322
|
+
list of detected objects, optionally with SExtractor checkimages.
|
|
1323
|
+
|
|
1324
|
+
See the `SExtractor documentation
|
|
1325
|
+
<https://sextractor.readthedocs.io/en/latest/>`_ for details.
|
|
1326
|
+
Detection flags are documented at
|
|
1327
|
+
`<https://sextractor.readthedocs.io/en/latest/Flagging.html#extraction-flags-flags>`_.
|
|
1328
|
+
Objects with masked pixels in their footprint additionally get flag
|
|
1329
|
+
``0x100``.
|
|
1330
|
+
|
|
1331
|
+
Parameters
|
|
1332
|
+
----------
|
|
1333
|
+
image : numpy.ndarray
|
|
1334
|
+
Input 2-D image.
|
|
1335
|
+
header : astropy.io.fits.Header, optional
|
|
1336
|
+
Image header.
|
|
1337
|
+
mask : numpy.ndarray of bool, optional
|
|
1338
|
+
Pixel mask (True = masked). Used for SExtractor flagging
|
|
1339
|
+
(FLAG_IMAGE / IMAFLAGS_ISO). Objects overlapping this mask get
|
|
1340
|
+
flag 0x100.
|
|
1341
|
+
mask_detect : numpy.ndarray of bool, optional
|
|
1342
|
+
Detection mask (True = exclude). When *mask_to_nans* is True,
|
|
1343
|
+
these pixels (plus non-finite pixels) are set to NaN before
|
|
1344
|
+
running SExtractor.
|
|
1345
|
+
err : numpy.ndarray, optional
|
|
1346
|
+
Noise map.
|
|
1347
|
+
thresh : float
|
|
1348
|
+
Detection threshold in sigmas (``DETECT_THRESH``).
|
|
1349
|
+
aper : float or list of float
|
|
1350
|
+
Aperture radius in pixels. If a list, flux is measured for
|
|
1351
|
+
each aperture.
|
|
1352
|
+
r0 : float
|
|
1353
|
+
Smoothing kernel sigma (pixels, i.e. FWHM / 2.355).
|
|
1354
|
+
gain : float
|
|
1355
|
+
Detector gain, e-/ADU.
|
|
1356
|
+
edge : int
|
|
1357
|
+
Reject objects closer to edge than this many pixels.
|
|
1358
|
+
minarea : int
|
|
1359
|
+
Minimum object area (``DETECT_MINAREA``).
|
|
1360
|
+
wcs : astropy.wcs.WCS, optional
|
|
1361
|
+
WCS for sky coordinate assignment.
|
|
1362
|
+
sn : float
|
|
1363
|
+
Minimum S/N for output.
|
|
1364
|
+
bg_size : int, optional
|
|
1365
|
+
Background grid size (``BACK_SIZE``).
|
|
1366
|
+
sort : bool
|
|
1367
|
+
Sort detections by decreasing brightness.
|
|
1368
|
+
reject_negative : bool
|
|
1369
|
+
Reject objects with negative flux.
|
|
1370
|
+
mask_to_nans : bool
|
|
1371
|
+
Replace detection-masked pixels with NaN before running
|
|
1372
|
+
SExtractor. Default True.
|
|
1373
|
+
checkimages : list of str
|
|
1374
|
+
SExtractor checkimage types to return (e.g. ``'BACKGROUND'``,
|
|
1375
|
+
``'SEGMENTATION'``).
|
|
1376
|
+
extra_params : list of str
|
|
1377
|
+
Extra SExtractor output parameters (see ``sex -dp``).
|
|
1378
|
+
extra : dict
|
|
1379
|
+
Extra SExtractor configuration parameters (see ``sex -dd``).
|
|
1380
|
+
psf : str, optional
|
|
1381
|
+
Path to PSFEx PSF model file for PSF photometry.
|
|
1382
|
+
catfile : str, optional
|
|
1383
|
+
Copy the output catalogue to this path.
|
|
1384
|
+
_workdir : str, optional
|
|
1385
|
+
Keep temporary files in this directory (for debugging).
|
|
1386
|
+
_tmpdir : str, optional
|
|
1387
|
+
Create temp directory inside this path.
|
|
1388
|
+
_exe : str, optional
|
|
1389
|
+
Full path to SExtractor executable.
|
|
1390
|
+
verbose : bool or callable
|
|
1391
|
+
Print progress messages.
|
|
1392
|
+
|
|
1393
|
+
Returns
|
|
1394
|
+
-------
|
|
1395
|
+
astropy.table.Table or list
|
|
1396
|
+
Table of detected objects. If *checkimages* is non-empty,
|
|
1397
|
+
returns ``[table, checkimage1, checkimage2, ...]``.
|
|
1398
|
+
"""
|
|
1399
|
+
|
|
1400
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1401
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1402
|
+
|
|
1403
|
+
# Find the binary
|
|
1404
|
+
binname = None
|
|
1405
|
+
|
|
1406
|
+
if _exe is not None:
|
|
1407
|
+
# Check user-provided binary path, and fail if not found
|
|
1408
|
+
if os.path.isfile(_exe):
|
|
1409
|
+
binname = _exe
|
|
1410
|
+
else:
|
|
1411
|
+
# Find SExtractor binary in common paths
|
|
1412
|
+
for exe in ['sex', 'sextractor', 'source-extractor']:
|
|
1413
|
+
binname = shutil.which(exe)
|
|
1414
|
+
if binname is not None:
|
|
1415
|
+
break
|
|
1416
|
+
|
|
1417
|
+
if binname is None:
|
|
1418
|
+
log("Can't find SExtractor binary")
|
|
1419
|
+
return None
|
|
1420
|
+
# else:
|
|
1421
|
+
# log("Using SExtractor binary at", binname)
|
|
1422
|
+
|
|
1423
|
+
workdir = _workdir if _workdir is not None else tempfile.mkdtemp(prefix='sex', dir=_tmpdir)
|
|
1424
|
+
obj = None
|
|
1425
|
+
|
|
1426
|
+
if mask is None:
|
|
1427
|
+
# Create minimal mask
|
|
1428
|
+
mask = ~np.isfinite(image)
|
|
1429
|
+
else:
|
|
1430
|
+
# Ensure the mask is boolean array
|
|
1431
|
+
mask = mask.astype(bool)
|
|
1432
|
+
|
|
1433
|
+
# Build detection mask: non-finite pixels plus optional user-provided
|
|
1434
|
+
# detection mask. When mask_to_nans is True, these pixels are set to NaN
|
|
1435
|
+
# so that SExtractor ignores them during detection and deblending. The
|
|
1436
|
+
# user mask (FLAG_IMAGE) is kept separate for flagging only, so that
|
|
1437
|
+
# bright objects with partially masked cores are not fragmented.
|
|
1438
|
+
mask_det = ~np.isfinite(image)
|
|
1439
|
+
if mask_detect is not None:
|
|
1440
|
+
mask_det = mask_det | np.asarray(mask_detect, dtype=bool)
|
|
1441
|
+
|
|
1442
|
+
if mask_to_nans and mask_det.any():
|
|
1443
|
+
image = (
|
|
1444
|
+
image.copy() if np.issubdtype(image.dtype, np.floating) else image.astype(np.float64)
|
|
1445
|
+
)
|
|
1446
|
+
image[mask_det] = np.nan
|
|
1447
|
+
|
|
1448
|
+
# Prepare
|
|
1449
|
+
if type(image) == str:
|
|
1450
|
+
# FIXME: this mode of operation is currently broken!
|
|
1451
|
+
imagename = image
|
|
1452
|
+
else:
|
|
1453
|
+
imagename = os.path.join(workdir, 'image.fits')
|
|
1454
|
+
fits.writeto(imagename, image, header, overwrite=True)
|
|
1455
|
+
|
|
1456
|
+
# Dummy config filename, to prevent loading from current dir
|
|
1457
|
+
confname = os.path.join(workdir, 'empty.conf')
|
|
1458
|
+
utils.file_write(confname)
|
|
1459
|
+
|
|
1460
|
+
opts = {
|
|
1461
|
+
'c': confname,
|
|
1462
|
+
'VERBOSE_TYPE': 'QUIET',
|
|
1463
|
+
'DETECT_MINAREA': minarea,
|
|
1464
|
+
'GAIN': gain,
|
|
1465
|
+
'DETECT_THRESH': thresh,
|
|
1466
|
+
'WEIGHT_TYPE': 'BACKGROUND',
|
|
1467
|
+
'MASK_TYPE': 'NONE', # both 'CORRECT' and 'BLANK' seem to cause systematics?
|
|
1468
|
+
'SATUR_LEVEL': np.nanmax(image[~mask]) + 1, # Saturation should be handled in external mask
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if bg_size is not None:
|
|
1472
|
+
opts['BACK_SIZE'] = bg_size
|
|
1473
|
+
|
|
1474
|
+
if err is not None:
|
|
1475
|
+
# User-provided noise model
|
|
1476
|
+
err = err.copy().astype(np.double)
|
|
1477
|
+
err[~np.isfinite(err)] = 1e30
|
|
1478
|
+
err[err == 0] = 1e30
|
|
1479
|
+
|
|
1480
|
+
errname = os.path.join(workdir, 'errors.fits')
|
|
1481
|
+
fits.writeto(errname, err, overwrite=True)
|
|
1482
|
+
opts['WEIGHT_IMAGE'] = errname
|
|
1483
|
+
opts['WEIGHT_TYPE'] = 'MAP_RMS'
|
|
1484
|
+
|
|
1485
|
+
flagsname = os.path.join(workdir, 'flags.fits')
|
|
1486
|
+
fits.writeto(flagsname, mask.astype(np.int16), overwrite=True)
|
|
1487
|
+
opts['FLAG_IMAGE'] = flagsname
|
|
1488
|
+
|
|
1489
|
+
if np.isscalar(aper):
|
|
1490
|
+
opts['PHOT_APERTURES'] = aper * 2 # SExtractor expects diameters, not radii
|
|
1491
|
+
size = ''
|
|
1492
|
+
else:
|
|
1493
|
+
opts['PHOT_APERTURES'] = ','.join([str(_ * 2) for _ in aper])
|
|
1494
|
+
size = '[%d]' % len(aper)
|
|
1495
|
+
|
|
1496
|
+
checknames = [os.path.join(workdir, _.replace('-', 'M_') + '.fits') for _ in checkimages]
|
|
1497
|
+
if checkimages:
|
|
1498
|
+
opts['CHECKIMAGE_TYPE'] = ','.join(checkimages)
|
|
1499
|
+
opts['CHECKIMAGE_NAME'] = ','.join(checknames)
|
|
1500
|
+
|
|
1501
|
+
params = [
|
|
1502
|
+
'MAG_APER' + size,
|
|
1503
|
+
'MAGERR_APER' + size,
|
|
1504
|
+
'FLUX_APER' + size,
|
|
1505
|
+
'FLUXERR_APER' + size,
|
|
1506
|
+
'X_IMAGE',
|
|
1507
|
+
'Y_IMAGE',
|
|
1508
|
+
'ERRX2_IMAGE',
|
|
1509
|
+
'ERRY2_IMAGE',
|
|
1510
|
+
'A_IMAGE',
|
|
1511
|
+
'B_IMAGE',
|
|
1512
|
+
'THETA_IMAGE',
|
|
1513
|
+
'FLUX_RADIUS',
|
|
1514
|
+
'FWHM_IMAGE',
|
|
1515
|
+
'FLAGS',
|
|
1516
|
+
'IMAFLAGS_ISO',
|
|
1517
|
+
'BACKGROUND',
|
|
1518
|
+
]
|
|
1519
|
+
params += extra_params
|
|
1520
|
+
|
|
1521
|
+
if psf is not None:
|
|
1522
|
+
opts['PSF_NAME'] = psf
|
|
1523
|
+
params += [
|
|
1524
|
+
'MAG_PSF',
|
|
1525
|
+
'MAGERR_PSF',
|
|
1526
|
+
'FLUX_PSF',
|
|
1527
|
+
'FLUXERR_PSF',
|
|
1528
|
+
'XPSF_IMAGE',
|
|
1529
|
+
'YPSF_IMAGE',
|
|
1530
|
+
'SPREAD_MODEL',
|
|
1531
|
+
'SPREADERR_MODEL',
|
|
1532
|
+
'CHI2_PSF',
|
|
1533
|
+
]
|
|
1534
|
+
|
|
1535
|
+
paramname = os.path.join(workdir, 'cfg.param')
|
|
1536
|
+
with open(paramname, 'w') as paramfile:
|
|
1537
|
+
paramfile.write("\n".join(params))
|
|
1538
|
+
opts['PARAMETERS_NAME'] = paramname
|
|
1539
|
+
|
|
1540
|
+
catname = os.path.join(workdir, 'out.cat')
|
|
1541
|
+
opts['CATALOG_NAME'] = catname
|
|
1542
|
+
opts['CATALOG_TYPE'] = 'FITS_LDAC'
|
|
1543
|
+
|
|
1544
|
+
if not r0:
|
|
1545
|
+
opts['FILTER'] = 'N'
|
|
1546
|
+
else:
|
|
1547
|
+
kernel = make_kernel(r0, ext=2.0)
|
|
1548
|
+
kernelname = os.path.join(workdir, 'kernel.txt')
|
|
1549
|
+
np.savetxt(
|
|
1550
|
+
kernelname,
|
|
1551
|
+
kernel / np.sum(kernel),
|
|
1552
|
+
fmt='%.6f',
|
|
1553
|
+
header='CONV NORM',
|
|
1554
|
+
comments='',
|
|
1555
|
+
)
|
|
1556
|
+
opts['FILTER'] = 'Y'
|
|
1557
|
+
opts['FILTER_NAME'] = kernelname
|
|
1558
|
+
|
|
1559
|
+
opts.update(extra)
|
|
1560
|
+
|
|
1561
|
+
# Build the command line
|
|
1562
|
+
cmd = binname + ' ' + shlex.quote(imagename) + ' ' + utils.format_astromatic_opts(opts)
|
|
1563
|
+
if not verbose:
|
|
1564
|
+
cmd += ' > /dev/null 2>/dev/null'
|
|
1565
|
+
log('Will run SExtractor like that:')
|
|
1566
|
+
log(cmd)
|
|
1567
|
+
|
|
1568
|
+
# Run the command!
|
|
1569
|
+
|
|
1570
|
+
res = os.system(cmd)
|
|
1571
|
+
|
|
1572
|
+
if res == 0 and os.path.exists(catname):
|
|
1573
|
+
log('SExtractor run succeeded')
|
|
1574
|
+
obj = Table.read(catname, hdu=2)
|
|
1575
|
+
obj.meta.clear() # Remove unnecessary entries from the metadata
|
|
1576
|
+
|
|
1577
|
+
idx = (obj['X_IMAGE'] > edge) & (obj['X_IMAGE'] < image.shape[1] - edge)
|
|
1578
|
+
idx &= (obj['Y_IMAGE'] > edge) & (obj['Y_IMAGE'] < image.shape[0] - edge)
|
|
1579
|
+
|
|
1580
|
+
if np.isscalar(aper):
|
|
1581
|
+
if sn:
|
|
1582
|
+
idx &= obj['MAGERR_APER'] < 1.0 / sn
|
|
1583
|
+
if reject_negative:
|
|
1584
|
+
idx &= obj['FLUX_APER'] > 0
|
|
1585
|
+
else:
|
|
1586
|
+
if sn:
|
|
1587
|
+
idx &= np.all(obj['MAGERR_APER'] < 1.0 / sn, axis=1)
|
|
1588
|
+
if reject_negative:
|
|
1589
|
+
idx &= np.all(obj['FLUX_APER'] > 0, axis=1)
|
|
1590
|
+
|
|
1591
|
+
obj = obj[idx]
|
|
1592
|
+
|
|
1593
|
+
if wcs is None and header is not None:
|
|
1594
|
+
wcs = WCS(header)
|
|
1595
|
+
|
|
1596
|
+
if wcs is not None:
|
|
1597
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['X_IMAGE'], obj['Y_IMAGE'], 1)
|
|
1598
|
+
else:
|
|
1599
|
+
obj['ra'], obj['dec'] = (
|
|
1600
|
+
np.zeros_like(obj['X_IMAGE']),
|
|
1601
|
+
np.zeros_like(obj['Y_IMAGE']),
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1604
|
+
obj['FLAGS'][obj['IMAFLAGS_ISO'] > 0] |= 0x100 # Masked pixels in the footprint
|
|
1605
|
+
obj.remove_column('IMAFLAGS_ISO') # We do not need this column
|
|
1606
|
+
|
|
1607
|
+
# Convert variances to rms
|
|
1608
|
+
obj['ERRX2_IMAGE'] = np.sqrt(obj['ERRX2_IMAGE'])
|
|
1609
|
+
obj['ERRY2_IMAGE'] = np.sqrt(obj['ERRY2_IMAGE'])
|
|
1610
|
+
|
|
1611
|
+
for _, __ in [
|
|
1612
|
+
['X_IMAGE', 'x'],
|
|
1613
|
+
['Y_IMAGE', 'y'],
|
|
1614
|
+
['ERRX2_IMAGE', 'xerr'],
|
|
1615
|
+
['ERRY2_IMAGE', 'yerr'],
|
|
1616
|
+
['FLUX_APER', 'flux'],
|
|
1617
|
+
['FLUXERR_APER', 'fluxerr'],
|
|
1618
|
+
['MAG_APER', 'mag'],
|
|
1619
|
+
['MAGERR_APER', 'magerr'],
|
|
1620
|
+
['BACKGROUND', 'bg'],
|
|
1621
|
+
['FLAGS', 'flags'],
|
|
1622
|
+
['FWHM_IMAGE', 'fwhm'],
|
|
1623
|
+
['A_IMAGE', 'a'],
|
|
1624
|
+
['B_IMAGE', 'b'],
|
|
1625
|
+
['THETA_IMAGE', 'theta'],
|
|
1626
|
+
]:
|
|
1627
|
+
obj.rename_column(_, __)
|
|
1628
|
+
|
|
1629
|
+
if psf:
|
|
1630
|
+
for _, __ in [
|
|
1631
|
+
['XPSF_IMAGE', 'x_psf'],
|
|
1632
|
+
['YPSF_IMAGE', 'y_psf'],
|
|
1633
|
+
['MAG_PSF', 'mag_psf'],
|
|
1634
|
+
['MAGERR_PSF', 'magerr_psf'],
|
|
1635
|
+
['FLUX_PSF', 'flux_psf'],
|
|
1636
|
+
['FLUXERR_PSF', 'fluxerr_psf'],
|
|
1637
|
+
['CHI2_PSF', 'chi2_psf'],
|
|
1638
|
+
['SPREAD_MODEL', 'spread_model'],
|
|
1639
|
+
['SPREADERR_MODEL', 'spreaderr_model'],
|
|
1640
|
+
]:
|
|
1641
|
+
if _ in obj.keys():
|
|
1642
|
+
obj.rename_column(_, __)
|
|
1643
|
+
if 'mag' in __:
|
|
1644
|
+
obj[__][obj[__] == 99] = np.nan # TODO: use masked column here?
|
|
1645
|
+
|
|
1646
|
+
# SExtractor uses 1-based pixel coordinates
|
|
1647
|
+
obj['x'] -= 1
|
|
1648
|
+
obj['y'] -= 1
|
|
1649
|
+
|
|
1650
|
+
if 'x_psf' in obj.keys():
|
|
1651
|
+
obj['x_psf'] -= 1
|
|
1652
|
+
obj['y_psf'] -= 1
|
|
1653
|
+
|
|
1654
|
+
obj.meta['aper'] = aper
|
|
1655
|
+
|
|
1656
|
+
if sort:
|
|
1657
|
+
if np.isscalar(aper):
|
|
1658
|
+
obj.sort('flux', reverse=True)
|
|
1659
|
+
else:
|
|
1660
|
+
# Table sorting by vector columns seems to be broken?..
|
|
1661
|
+
obj = obj[np.argsort(-obj['flux'][:, 0])]
|
|
1662
|
+
|
|
1663
|
+
if catfile is not None:
|
|
1664
|
+
shutil.copyfile(catname, catfile)
|
|
1665
|
+
log("Catalogue stored to", catfile)
|
|
1666
|
+
|
|
1667
|
+
else:
|
|
1668
|
+
log("Error", res, "running SExtractor")
|
|
1669
|
+
|
|
1670
|
+
result = obj
|
|
1671
|
+
|
|
1672
|
+
if checkimages:
|
|
1673
|
+
result = [result]
|
|
1674
|
+
|
|
1675
|
+
for name in checknames:
|
|
1676
|
+
if os.path.exists(name):
|
|
1677
|
+
result.append(fits.getdata(name))
|
|
1678
|
+
else:
|
|
1679
|
+
log("Cannot find requested output checkimage file", name)
|
|
1680
|
+
result.append(None)
|
|
1681
|
+
|
|
1682
|
+
if _workdir is None:
|
|
1683
|
+
shutil.rmtree(workdir)
|
|
1684
|
+
|
|
1685
|
+
return result
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def _empty_table(get_segmentation=False):
|
|
1689
|
+
"""
|
|
1690
|
+
Return empty table with correct structure.
|
|
1691
|
+
|
|
1692
|
+
Parameters
|
|
1693
|
+
----------
|
|
1694
|
+
get_segmentation : bool
|
|
1695
|
+
If True, return tuple (table, None), otherwise just table
|
|
1696
|
+
|
|
1697
|
+
Returns
|
|
1698
|
+
-------
|
|
1699
|
+
table : astropy.table.Table
|
|
1700
|
+
Empty table with standard columns
|
|
1701
|
+
segm : None (optional)
|
|
1702
|
+
None segmentation map if get_segmentation=True
|
|
1703
|
+
"""
|
|
1704
|
+
obj = Table()
|
|
1705
|
+
obj['x'] = []
|
|
1706
|
+
obj['y'] = []
|
|
1707
|
+
obj['flux'] = []
|
|
1708
|
+
obj['fluxerr'] = []
|
|
1709
|
+
obj['mag'] = []
|
|
1710
|
+
obj['magerr'] = []
|
|
1711
|
+
obj['fwhm'] = []
|
|
1712
|
+
obj['a'] = []
|
|
1713
|
+
obj['b'] = []
|
|
1714
|
+
obj['theta'] = []
|
|
1715
|
+
obj['bg'] = []
|
|
1716
|
+
obj['flags'] = []
|
|
1717
|
+
obj['xerr'] = []
|
|
1718
|
+
obj['yerr'] = []
|
|
1719
|
+
|
|
1720
|
+
if get_segmentation:
|
|
1721
|
+
return obj, None
|
|
1722
|
+
else:
|
|
1723
|
+
return obj
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
class ConstantBackground:
|
|
1727
|
+
def __init__(self, value, rms, shape):
|
|
1728
|
+
self.background = np.full(shape, value)
|
|
1729
|
+
self.background_rms = np.full(shape, rms)
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def get_value(val):
|
|
1733
|
+
"""Get array from either Quantity or ndarray"""
|
|
1734
|
+
if hasattr(val, 'value'):
|
|
1735
|
+
return val.value
|
|
1736
|
+
else:
|
|
1737
|
+
return np.asarray(val)
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def get_objects_photutils(
|
|
1741
|
+
image,
|
|
1742
|
+
header=None,
|
|
1743
|
+
mask=None,
|
|
1744
|
+
mask_detect=None,
|
|
1745
|
+
err=None,
|
|
1746
|
+
# Detection parameters
|
|
1747
|
+
thresh=2.0,
|
|
1748
|
+
method='segmentation',
|
|
1749
|
+
deblend=True,
|
|
1750
|
+
minarea=5,
|
|
1751
|
+
saturation=None,
|
|
1752
|
+
# Segmentation-specific parameters
|
|
1753
|
+
npixels=5,
|
|
1754
|
+
nlevels=32,
|
|
1755
|
+
contrast=0.001,
|
|
1756
|
+
connectivity=8,
|
|
1757
|
+
# StarFinder-specific parameters
|
|
1758
|
+
fwhm=3.0,
|
|
1759
|
+
sharplo=0.2,
|
|
1760
|
+
sharphi=1.0,
|
|
1761
|
+
roundlo=-1.0,
|
|
1762
|
+
roundhi=1.0,
|
|
1763
|
+
# Photometry parameters
|
|
1764
|
+
aper=3.0,
|
|
1765
|
+
bkgann=None,
|
|
1766
|
+
# Background parameters
|
|
1767
|
+
bg_size=64,
|
|
1768
|
+
subtract_bg=True,
|
|
1769
|
+
# Filtering parameters
|
|
1770
|
+
edge=0,
|
|
1771
|
+
sn=3.0,
|
|
1772
|
+
# Output control
|
|
1773
|
+
wcs=None,
|
|
1774
|
+
get_segmentation=False,
|
|
1775
|
+
verbose=False,
|
|
1776
|
+
**kwargs,
|
|
1777
|
+
):
|
|
1778
|
+
"""
|
|
1779
|
+
Detect sources in image using photutils detection algorithms.
|
|
1780
|
+
|
|
1781
|
+
This function provides an alternative to get_objects_sep() using
|
|
1782
|
+
photutils instead of SEP. It supports multiple detection methods
|
|
1783
|
+
including segmentation-based detection with deblending and
|
|
1784
|
+
point source detection using DAOStarFinder or IRAFStarFinder.
|
|
1785
|
+
|
|
1786
|
+
Parameters
|
|
1787
|
+
----------
|
|
1788
|
+
image : numpy.ndarray
|
|
1789
|
+
2D image array
|
|
1790
|
+
header : astropy.io.fits.Header, optional
|
|
1791
|
+
FITS header for WCS information
|
|
1792
|
+
mask : numpy.ndarray, optional
|
|
1793
|
+
Boolean mask (True = masked pixel). Used for background estimation,
|
|
1794
|
+
photometry, and flagging (0x100). Not used for detection itself.
|
|
1795
|
+
mask_detect : numpy.ndarray, optional
|
|
1796
|
+
Boolean mask for detection (True = exclude from detection). OR-ed with
|
|
1797
|
+
non-finite pixel mask. If None, only non-finite pixels are excluded
|
|
1798
|
+
from detection.
|
|
1799
|
+
err : numpy.ndarray, optional
|
|
1800
|
+
Error/uncertainty map. If None, uses background RMS
|
|
1801
|
+
thresh : float, optional
|
|
1802
|
+
Detection threshold in sigma units (default: 2.0)
|
|
1803
|
+
method : str, optional
|
|
1804
|
+
Detection method: 'segmentation', 'dao', or 'iraf' (default: 'segmentation')
|
|
1805
|
+
deblend : bool, optional
|
|
1806
|
+
Apply deblending for segmentation method (default: True)
|
|
1807
|
+
minarea : int, optional
|
|
1808
|
+
Minimum source area in pixels (default: 5)
|
|
1809
|
+
saturation : float, optional
|
|
1810
|
+
Saturation threshold for flagging saturated sources. Sources with
|
|
1811
|
+
peak pixel values exceeding this threshold will have flag 0x004 set.
|
|
1812
|
+
If None (default), saturation detection is disabled.
|
|
1813
|
+
npixels : int, optional
|
|
1814
|
+
Minimum connected pixels for segmentation (default: 5)
|
|
1815
|
+
nlevels : int, optional
|
|
1816
|
+
Number of deblending levels (default: 32)
|
|
1817
|
+
contrast : float, optional
|
|
1818
|
+
Deblending contrast threshold (default: 0.001)
|
|
1819
|
+
connectivity : int, optional
|
|
1820
|
+
Pixel connectivity (4 or 8) for segmentation (default: 8)
|
|
1821
|
+
fwhm : float, optional
|
|
1822
|
+
FWHM for StarFinder methods in pixels (default: 3.0)
|
|
1823
|
+
sharplo : float, optional
|
|
1824
|
+
Lower sharpness bound for StarFinder (default: 0.2)
|
|
1825
|
+
sharphi : float, optional
|
|
1826
|
+
Upper sharpness bound for StarFinder (default: 1.0)
|
|
1827
|
+
roundlo : float, optional
|
|
1828
|
+
Lower roundness bound for StarFinder (default: -1.0)
|
|
1829
|
+
roundhi : float, optional
|
|
1830
|
+
Upper roundness bound for StarFinder (default: 1.0)
|
|
1831
|
+
aper : float, optional
|
|
1832
|
+
Aperture radius in pixels (default: 3.0)
|
|
1833
|
+
bkgann : tuple, optional
|
|
1834
|
+
Background annulus (inner, outer) radii in pixels
|
|
1835
|
+
bg_size : int, optional
|
|
1836
|
+
Background estimation box size (default: 64)
|
|
1837
|
+
subtract_bg : bool, optional
|
|
1838
|
+
Subtract background before detection (default: True)
|
|
1839
|
+
edge : int, optional
|
|
1840
|
+
Edge exclusion in pixels (default: 0)
|
|
1841
|
+
sn : float, optional
|
|
1842
|
+
Minimum S/N ratio (default: 3.0)
|
|
1843
|
+
wcs : astropy.wcs.WCS, optional
|
|
1844
|
+
WCS for coordinate conversion. If None, extracted from header
|
|
1845
|
+
get_segmentation : bool, optional
|
|
1846
|
+
Return segmentation map (only for method='segmentation') (default: False)
|
|
1847
|
+
verbose : bool or callable, optional
|
|
1848
|
+
Verbose output (default: False)
|
|
1849
|
+
**kwargs
|
|
1850
|
+
Additional parameters passed to photutils functions
|
|
1851
|
+
|
|
1852
|
+
Returns
|
|
1853
|
+
-------
|
|
1854
|
+
table : astropy.table.Table
|
|
1855
|
+
Table of detected sources with columns:
|
|
1856
|
+
- x, y: pixel coordinates (0-indexed)
|
|
1857
|
+
- xerr, yerr: position errors
|
|
1858
|
+
- flux, fluxerr: aperture flux and error
|
|
1859
|
+
- mag, magerr: instrumental magnitude and error
|
|
1860
|
+
- fwhm: FWHM estimate
|
|
1861
|
+
- a, b: semi-major and semi-minor axes
|
|
1862
|
+
- theta: position angle
|
|
1863
|
+
- bg: local background
|
|
1864
|
+
- flags: detection flags
|
|
1865
|
+
- ra, dec: WCS coordinates (if WCS available)
|
|
1866
|
+
segm : photutils.segmentation.SegmentationImage, optional
|
|
1867
|
+
Segmentation map (only if get_segmentation=True and method='segmentation')
|
|
1868
|
+
|
|
1869
|
+
Notes
|
|
1870
|
+
-----
|
|
1871
|
+
The function follows the same workflow as get_objects_sep():
|
|
1872
|
+
1. Validate inputs and create mask from NaNs
|
|
1873
|
+
2. Estimate background using Background2D
|
|
1874
|
+
3. Optionally subtract background
|
|
1875
|
+
4. Detect sources using selected method
|
|
1876
|
+
5. Measure aperture photometry
|
|
1877
|
+
6. Apply quality filters
|
|
1878
|
+
7. Add WCS coordinates if available
|
|
1879
|
+
8. Return table with standard columns
|
|
1880
|
+
|
|
1881
|
+
Detection methods:
|
|
1882
|
+
- 'segmentation': Uses detect_sources() with optional deblending.
|
|
1883
|
+
Best for extended sources and crowded fields.
|
|
1884
|
+
- 'dao': Uses DAOStarFinder for point sources.
|
|
1885
|
+
Best for stellar fields with well-defined PSF.
|
|
1886
|
+
- 'iraf': Uses IRAFStarFinder for IRAF-compatible detection.
|
|
1887
|
+
Similar to 'dao' but with IRAF-style parameters.
|
|
1888
|
+
|
|
1889
|
+
Examples
|
|
1890
|
+
--------
|
|
1891
|
+
Basic segmentation detection:
|
|
1892
|
+
|
|
1893
|
+
>>> obj = get_objects_photutils(image, thresh=3.0, aper=5.0)
|
|
1894
|
+
|
|
1895
|
+
Segmentation with deblending:
|
|
1896
|
+
|
|
1897
|
+
>>> obj = get_objects_photutils(
|
|
1898
|
+
... image, method='segmentation', deblend=True,
|
|
1899
|
+
... nlevels=32, contrast=0.001
|
|
1900
|
+
... )
|
|
1901
|
+
|
|
1902
|
+
Point source detection:
|
|
1903
|
+
|
|
1904
|
+
>>> obj = get_objects_photutils(
|
|
1905
|
+
... image, method='dao', fwhm=3.0, thresh=5.0
|
|
1906
|
+
... )
|
|
1907
|
+
|
|
1908
|
+
With background annulus:
|
|
1909
|
+
|
|
1910
|
+
>>> obj = get_objects_photutils(
|
|
1911
|
+
... image, aper=5.0, bkgann=(10.0, 15.0)
|
|
1912
|
+
... )
|
|
1913
|
+
|
|
1914
|
+
Get segmentation map:
|
|
1915
|
+
|
|
1916
|
+
>>> obj, segm = get_objects_photutils(
|
|
1917
|
+
... image, method='segmentation', get_segmentation=True
|
|
1918
|
+
... )
|
|
1919
|
+
"""
|
|
1920
|
+
# Validate inputs
|
|
1921
|
+
if image.ndim != 2:
|
|
1922
|
+
raise ValueError("Image must be 2D array")
|
|
1923
|
+
|
|
1924
|
+
# Setup logging
|
|
1925
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1926
|
+
|
|
1927
|
+
# Create detection mask from NaNs and optional mask_detect
|
|
1928
|
+
mask_det = ~np.isfinite(image)
|
|
1929
|
+
if mask_detect is not None:
|
|
1930
|
+
mask_det = mask_det | np.asarray(mask_detect, dtype=bool)
|
|
1931
|
+
|
|
1932
|
+
if mask is None:
|
|
1933
|
+
mask = np.zeros(image.shape, dtype=bool)
|
|
1934
|
+
else:
|
|
1935
|
+
mask = np.array(mask).astype(bool)
|
|
1936
|
+
|
|
1937
|
+
# Saturation map
|
|
1938
|
+
if saturation is not None:
|
|
1939
|
+
log(f'Using saturation level {saturation:.2f}')
|
|
1940
|
+
|
|
1941
|
+
smask = image >= saturation
|
|
1942
|
+
|
|
1943
|
+
# Let's subtract saturation mask from user-provided one so that it does not prevent proper segmentation
|
|
1944
|
+
mask = mask & (~smask)
|
|
1945
|
+
else:
|
|
1946
|
+
smask = np.zeros(image.shape, dtype=bool)
|
|
1947
|
+
|
|
1948
|
+
total_mask = mask | mask_det | smask
|
|
1949
|
+
|
|
1950
|
+
# Extract WCS from header if provided
|
|
1951
|
+
if wcs is None and header is not None:
|
|
1952
|
+
try:
|
|
1953
|
+
wcs = WCS(header)
|
|
1954
|
+
if not wcs.is_celestial:
|
|
1955
|
+
wcs = None
|
|
1956
|
+
except Exception:
|
|
1957
|
+
wcs = None
|
|
1958
|
+
|
|
1959
|
+
# Estimate background using photutils
|
|
1960
|
+
log(f'Estimating background with {bg_size}x{bg_size} grid')
|
|
1961
|
+
|
|
1962
|
+
try:
|
|
1963
|
+
bkg = photutils.background.Background2D(
|
|
1964
|
+
image,
|
|
1965
|
+
box_size=bg_size,
|
|
1966
|
+
mask=total_mask,
|
|
1967
|
+
bkg_estimator=photutils.background.ModeEstimatorBackground(),
|
|
1968
|
+
exclude_percentile=10,
|
|
1969
|
+
)
|
|
1970
|
+
except Exception as e:
|
|
1971
|
+
log(f'Warning: Background estimation failed: {e}')
|
|
1972
|
+
# Fallback to simple median
|
|
1973
|
+
bkg = ConstantBackground(
|
|
1974
|
+
np.nanmedian(image[~total_mask]) if np.any(~total_mask) else 0.0,
|
|
1975
|
+
np.nanstd(image[~total_mask]) if np.any(~total_mask) else 1.0,
|
|
1976
|
+
image.shape,
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
# These things are not cached internally, so let's avoid re-computing them
|
|
1980
|
+
bkg_back = bkg.background
|
|
1981
|
+
bkg_rms = bkg.background_rms
|
|
1982
|
+
|
|
1983
|
+
# Optionally subtract background
|
|
1984
|
+
if subtract_bg:
|
|
1985
|
+
image_bgsub = image - bkg.background
|
|
1986
|
+
log(
|
|
1987
|
+
f'Subtracting background: median {np.nanmedian(bkg_back):.2f}, '
|
|
1988
|
+
f'rms {np.nanmedian(bkg_rms):.2f}'
|
|
1989
|
+
)
|
|
1990
|
+
else:
|
|
1991
|
+
image_bgsub = image.copy()
|
|
1992
|
+
|
|
1993
|
+
# Create or use error map
|
|
1994
|
+
if err is None:
|
|
1995
|
+
err = bkg_rms
|
|
1996
|
+
log('Using background RMS as error map')
|
|
1997
|
+
else:
|
|
1998
|
+
log('Using provided error map')
|
|
1999
|
+
|
|
2000
|
+
# Initialize variables for different methods
|
|
2001
|
+
segm = None
|
|
2002
|
+
catalog = None
|
|
2003
|
+
sources = None
|
|
2004
|
+
|
|
2005
|
+
# Source detection based on different methods
|
|
2006
|
+
if method == 'segmentation':
|
|
2007
|
+
# Detect sources via segmentation
|
|
2008
|
+
threshold = thresh * err
|
|
2009
|
+
|
|
2010
|
+
log(f'Detecting sources with threshold {thresh} sigma, at least {npixels} pixels')
|
|
2011
|
+
segm = photutils.segmentation.detect_sources(
|
|
2012
|
+
image_bgsub,
|
|
2013
|
+
threshold=threshold,
|
|
2014
|
+
npixels=npixels,
|
|
2015
|
+
connectivity=connectivity,
|
|
2016
|
+
mask=mask_det,
|
|
2017
|
+
)
|
|
2018
|
+
|
|
2019
|
+
if segm is None or segm.nlabels == 0:
|
|
2020
|
+
log('No sources detected')
|
|
2021
|
+
return _empty_table(get_segmentation)
|
|
2022
|
+
|
|
2023
|
+
log(f'Detected {segm.nlabels} initial segments')
|
|
2024
|
+
|
|
2025
|
+
# Optionally deblend sources
|
|
2026
|
+
if deblend and segm.nlabels > 0:
|
|
2027
|
+
log(f'Deblending with nlevels {nlevels}, contrast {contrast}')
|
|
2028
|
+
try:
|
|
2029
|
+
segm_deblend = photutils.segmentation.deblend_sources(
|
|
2030
|
+
image_bgsub, segm, npixels=npixels, nlevels=nlevels, contrast=contrast
|
|
2031
|
+
)
|
|
2032
|
+
segm = segm_deblend
|
|
2033
|
+
log(f'Deblended to {segm.nlabels} sources')
|
|
2034
|
+
except Exception as e:
|
|
2035
|
+
log(f'Warning: Deblending failed: {e}')
|
|
2036
|
+
|
|
2037
|
+
# Extract source properties
|
|
2038
|
+
catalog = photutils.segmentation.SourceCatalog(
|
|
2039
|
+
image_bgsub,
|
|
2040
|
+
segm,
|
|
2041
|
+
error=err,
|
|
2042
|
+
mask=mask_det,
|
|
2043
|
+
background=bkg_back if subtract_bg else None,
|
|
2044
|
+
)
|
|
2045
|
+
|
|
2046
|
+
# Convert to arrays (handle both Quantity and ndarray)
|
|
2047
|
+
x = get_value(catalog.xcentroid)
|
|
2048
|
+
y = get_value(catalog.ycentroid)
|
|
2049
|
+
area = get_value(catalog.area)
|
|
2050
|
+
|
|
2051
|
+
elif method in ['dao', 'iraf']:
|
|
2052
|
+
# Calculate threshold
|
|
2053
|
+
threshold_value = thresh * np.nanmedian(err)
|
|
2054
|
+
|
|
2055
|
+
# Select appropriate finder
|
|
2056
|
+
if method == 'dao':
|
|
2057
|
+
log(f'Using DAOStarFinder with fwhm={fwhm}, threshold={thresh} sigma')
|
|
2058
|
+
finder = photutils.detection.DAOStarFinder(
|
|
2059
|
+
fwhm=fwhm,
|
|
2060
|
+
threshold=threshold_value,
|
|
2061
|
+
sharplo=sharplo,
|
|
2062
|
+
sharphi=sharphi,
|
|
2063
|
+
roundlo=roundlo,
|
|
2064
|
+
roundhi=roundhi,
|
|
2065
|
+
exclude_border=True,
|
|
2066
|
+
)
|
|
2067
|
+
else: # method == 'iraf'
|
|
2068
|
+
log(f'Using IRAFStarFinder with fwhm={fwhm}, threshold={thresh} sigma')
|
|
2069
|
+
finder = photutils.detection.IRAFStarFinder(
|
|
2070
|
+
fwhm=fwhm,
|
|
2071
|
+
threshold=threshold_value,
|
|
2072
|
+
sharplo=sharplo,
|
|
2073
|
+
sharphi=sharphi,
|
|
2074
|
+
roundlo=roundlo,
|
|
2075
|
+
roundhi=roundhi,
|
|
2076
|
+
exclude_border=True,
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
# Find sources
|
|
2080
|
+
try:
|
|
2081
|
+
sources = finder(image_bgsub, mask=mask_det)
|
|
2082
|
+
except Exception as e:
|
|
2083
|
+
log(f'Warning: Source detection failed: {e}')
|
|
2084
|
+
sources = None
|
|
2085
|
+
|
|
2086
|
+
if sources is None or len(sources) == 0:
|
|
2087
|
+
log('No sources detected')
|
|
2088
|
+
return _empty_table(get_segmentation=False)
|
|
2089
|
+
|
|
2090
|
+
log(f'Detected {len(sources)} sources')
|
|
2091
|
+
|
|
2092
|
+
# Extract positions (handle both Quantity and ndarray)
|
|
2093
|
+
x = get_value(sources['xcentroid'])
|
|
2094
|
+
y = get_value(sources['ycentroid'])
|
|
2095
|
+
# StarFinder doesn't provide area, use approximate
|
|
2096
|
+
area = np.full(len(sources), np.pi * (fwhm / 2) ** 2)
|
|
2097
|
+
|
|
2098
|
+
else:
|
|
2099
|
+
raise ValueError(f"Unknown method: {method}. Use 'segmentation', 'dao', or 'iraf'")
|
|
2100
|
+
|
|
2101
|
+
# Aperture photometry
|
|
2102
|
+
log(f'Performing aperture photometry with {aper} pixels aperture')
|
|
2103
|
+
|
|
2104
|
+
# Create apertures at detected positions
|
|
2105
|
+
positions = np.column_stack([x, y])
|
|
2106
|
+
apertures = photutils.aperture.CircularAperture(positions, r=aper)
|
|
2107
|
+
|
|
2108
|
+
# Optional background annulus
|
|
2109
|
+
if bkgann is not None:
|
|
2110
|
+
log(f'Using background annulus: {bkgann}')
|
|
2111
|
+
bkg_apertures = photutils.aperture.CircularAnnulus(
|
|
2112
|
+
positions, r_in=bkgann[0], r_out=bkgann[1]
|
|
2113
|
+
)
|
|
2114
|
+
|
|
2115
|
+
# Measure background
|
|
2116
|
+
bkg_phot = photutils.aperture.aperture_photometry(
|
|
2117
|
+
image_bgsub, bkg_apertures, error=err, mask=mask
|
|
2118
|
+
)
|
|
2119
|
+
bkg_mean = bkg_phot['aperture_sum'] / bkg_apertures.area
|
|
2120
|
+
|
|
2121
|
+
# Apply background correction to aperture
|
|
2122
|
+
phot_table = photutils.aperture.aperture_photometry(
|
|
2123
|
+
image_bgsub, apertures, error=err, mask=mask
|
|
2124
|
+
)
|
|
2125
|
+
|
|
2126
|
+
# Handle both Quantity and ndarray
|
|
2127
|
+
aper_sum = get_value(phot_table['aperture_sum'])
|
|
2128
|
+
aper_sum_err = get_value(phot_table['aperture_sum_err'])
|
|
2129
|
+
bkg_mean_arr = get_value(bkg_mean)
|
|
2130
|
+
|
|
2131
|
+
flux = aper_sum - bkg_mean_arr * apertures.area
|
|
2132
|
+
fluxerr = aper_sum_err
|
|
2133
|
+
else:
|
|
2134
|
+
# Simple aperture photometry
|
|
2135
|
+
phot_table = photutils.aperture.aperture_photometry(
|
|
2136
|
+
image_bgsub, apertures, error=err, mask=mask
|
|
2137
|
+
)
|
|
2138
|
+
|
|
2139
|
+
# Handle both Quantity and ndarray
|
|
2140
|
+
flux = get_value(phot_table['aperture_sum'])
|
|
2141
|
+
fluxerr = get_value(phot_table['aperture_sum_err'])
|
|
2142
|
+
|
|
2143
|
+
# Convert to magnitudes
|
|
2144
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
2145
|
+
mag = -2.5 * np.log10(flux)
|
|
2146
|
+
magerr = 2.5 / np.log(10) * fluxerr / flux
|
|
2147
|
+
|
|
2148
|
+
# Build output table
|
|
2149
|
+
obj = Table()
|
|
2150
|
+
obj['x'] = x
|
|
2151
|
+
obj['y'] = y
|
|
2152
|
+
obj['flux'] = flux
|
|
2153
|
+
obj['fluxerr'] = fluxerr
|
|
2154
|
+
obj['mag'] = mag
|
|
2155
|
+
obj['magerr'] = magerr
|
|
2156
|
+
if segm is not None:
|
|
2157
|
+
obj['label'] = catalog.labels
|
|
2158
|
+
|
|
2159
|
+
# Add shape parameters
|
|
2160
|
+
if method == 'segmentation':
|
|
2161
|
+
# Extract from catalog (handle both Quantity and ndarray)
|
|
2162
|
+
semimajor = get_value(catalog.semimajor_sigma)
|
|
2163
|
+
semiminor = get_value(catalog.semiminor_sigma)
|
|
2164
|
+
orientation = get_value(catalog.orientation)
|
|
2165
|
+
|
|
2166
|
+
obj['a'] = semimajor * 2.355 # Convert sigma to FWHM
|
|
2167
|
+
obj['b'] = semiminor * 2.355
|
|
2168
|
+
obj['theta'] = orientation
|
|
2169
|
+
obj['fwhm'] = (obj['a'] + obj['b']) / 2
|
|
2170
|
+
obj['flags'] = np.zeros(len(obj), dtype=int)
|
|
2171
|
+
|
|
2172
|
+
# Set detection flags for segmentation method
|
|
2173
|
+
try:
|
|
2174
|
+
# 0x002: Deblended sources
|
|
2175
|
+
if deblend and segm is not None and hasattr(segm, 'deblended_labels'):
|
|
2176
|
+
deblended_labels = set(segm.deblended_labels)
|
|
2177
|
+
obj['flags'][np.isin(obj['label'], deblended_labels)] |= 0x002
|
|
2178
|
+
|
|
2179
|
+
# 0x008: Footprint truncated at image boundary
|
|
2180
|
+
bbox_xmin = get_value(catalog.bbox_xmin)
|
|
2181
|
+
bbox_xmax = get_value(catalog.bbox_xmax)
|
|
2182
|
+
bbox_ymin = get_value(catalog.bbox_ymin)
|
|
2183
|
+
bbox_ymax = get_value(catalog.bbox_ymax)
|
|
2184
|
+
|
|
2185
|
+
for i in range(len(obj)):
|
|
2186
|
+
if (
|
|
2187
|
+
bbox_xmin[i] == 0
|
|
2188
|
+
or bbox_xmax[i] >= image.shape[1]
|
|
2189
|
+
or bbox_ymin[i] == 0
|
|
2190
|
+
or bbox_ymax[i] >= image.shape[0]
|
|
2191
|
+
):
|
|
2192
|
+
obj['flags'][i] |= 0x008
|
|
2193
|
+
|
|
2194
|
+
# 0x004: Saturated sources (if saturation threshold provided)
|
|
2195
|
+
if saturation is not None:
|
|
2196
|
+
max_val = get_value(catalog.max_value)
|
|
2197
|
+
obj['flags'][max_val >= saturation] |= 0x004
|
|
2198
|
+
|
|
2199
|
+
except Exception as e:
|
|
2200
|
+
log(f'Warning: Flag detection failed for segmentation: {e}')
|
|
2201
|
+
|
|
2202
|
+
else: # StarFinder
|
|
2203
|
+
obj['fwhm'] = np.full(len(obj), fwhm)
|
|
2204
|
+
obj['a'] = obj['fwhm']
|
|
2205
|
+
obj['b'] = obj['fwhm']
|
|
2206
|
+
obj['theta'] = np.zeros(len(obj))
|
|
2207
|
+
obj['flags'] = np.zeros(len(obj), dtype=int)
|
|
2208
|
+
|
|
2209
|
+
# Set detection flags for StarFinder methods
|
|
2210
|
+
try:
|
|
2211
|
+
# 0x010: Poor quality metrics (sharpness/roundness near rejection thresholds)
|
|
2212
|
+
margin = 0.1 # 10% margin from thresholds
|
|
2213
|
+
|
|
2214
|
+
if 'sharpness' in sources.colnames:
|
|
2215
|
+
sharp = get_value(sources['sharpness'])
|
|
2216
|
+
poor_sharp = (sharp < sharplo * (1 + margin)) | (sharp > sharphi * (1 - margin))
|
|
2217
|
+
obj['flags'][poor_sharp] |= 0x010
|
|
2218
|
+
|
|
2219
|
+
if 'roundness1' in sources.colnames:
|
|
2220
|
+
round1 = get_value(sources['roundness1'])
|
|
2221
|
+
poor_round = (round1 < roundlo * (1 + margin)) | (round1 > roundhi * (1 - margin))
|
|
2222
|
+
obj['flags'][poor_round] |= 0x010
|
|
2223
|
+
|
|
2224
|
+
# 0x008: Truncated at boundary (near edge, even though exclude_border=True)
|
|
2225
|
+
near_edge = (x <= 1) | (x >= image.shape[1] - 2) | (y <= 1) | (y >= image.shape[0] - 2)
|
|
2226
|
+
obj['flags'][near_edge] |= 0x008
|
|
2227
|
+
|
|
2228
|
+
# 0x004: Saturated sources
|
|
2229
|
+
if saturation is not None and 'peak' in sources.colnames:
|
|
2230
|
+
peak = get_value(sources['peak'])
|
|
2231
|
+
obj['flags'][peak >= saturation] |= 0x004
|
|
2232
|
+
|
|
2233
|
+
except Exception as e:
|
|
2234
|
+
log(f'Warning: Flag detection failed for StarFinder: {e}')
|
|
2235
|
+
|
|
2236
|
+
# 0x100: User-masked pixels in footprint (both methods)
|
|
2237
|
+
try:
|
|
2238
|
+
if np.any(mask):
|
|
2239
|
+
if method == 'segmentation':
|
|
2240
|
+
# Use segmentation map
|
|
2241
|
+
labels = list(set(segm.data[mask])) # footprints with masked pixels
|
|
2242
|
+
obj['flags'][np.isin(obj['label'], labels)] |= 0x100
|
|
2243
|
+
else:
|
|
2244
|
+
# Check source apertures for masked pixels
|
|
2245
|
+
positions = np.column_stack([obj['x'], obj['y']])
|
|
2246
|
+
apertures = photutils.aperture.CircularAperture(positions, r=fwhm / 2)
|
|
2247
|
+
mres = aperture_photometry(mask.astype(int), apertures, method='center')
|
|
2248
|
+
obj['flags'][mres['aperture_sum'] > 0] |= 0x100
|
|
2249
|
+
except Exception as e:
|
|
2250
|
+
log(f'Warning: Masked pixel detection failed: {e}')
|
|
2251
|
+
|
|
2252
|
+
# Add background at each source
|
|
2253
|
+
if method == 'segmentation':
|
|
2254
|
+
bg_at_centroid = get_value(catalog.background_centroid)
|
|
2255
|
+
obj['bg'] = bg_at_centroid
|
|
2256
|
+
else:
|
|
2257
|
+
obj['bg'] = [
|
|
2258
|
+
bkg_back[_y, _x]
|
|
2259
|
+
if _x >= 0 and _x < image.shape[1] and _y >= 0 and _y < image.shape[0]
|
|
2260
|
+
else np.nan
|
|
2261
|
+
for _x, _y in zip(obj['x'].astype(int), obj['y'].astype(int))
|
|
2262
|
+
]
|
|
2263
|
+
|
|
2264
|
+
# Add position errors (simple estimate based on S/N)
|
|
2265
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
2266
|
+
positional_error = 0.5 / (flux / fluxerr)
|
|
2267
|
+
positional_error = np.clip(positional_error, 0.1, 5.0)
|
|
2268
|
+
|
|
2269
|
+
obj['xerr'] = positional_error
|
|
2270
|
+
obj['yerr'] = positional_error
|
|
2271
|
+
|
|
2272
|
+
# Add metadata
|
|
2273
|
+
obj.meta['aper'] = aper
|
|
2274
|
+
if bkgann is not None:
|
|
2275
|
+
obj.meta['bkgann'] = bkgann
|
|
2276
|
+
obj.meta['method'] = method
|
|
2277
|
+
obj.meta['thresh'] = thresh
|
|
2278
|
+
if method == 'segmentation':
|
|
2279
|
+
obj.meta['deblend'] = deblend
|
|
2280
|
+
|
|
2281
|
+
# Apply quality filters
|
|
2282
|
+
log(f'Applying quality filters: edge={edge}, sn={sn}, minarea={minarea}')
|
|
2283
|
+
|
|
2284
|
+
# Edge filter
|
|
2285
|
+
if edge > 0:
|
|
2286
|
+
idx = (obj['x'] > edge) & (obj['x'] < image.shape[1] - edge)
|
|
2287
|
+
idx &= (obj['y'] > edge) & (obj['y'] < image.shape[0] - edge)
|
|
2288
|
+
else:
|
|
2289
|
+
idx = np.ones(len(obj), dtype=bool)
|
|
2290
|
+
|
|
2291
|
+
# Area filter (for segmentation)
|
|
2292
|
+
if method == 'segmentation':
|
|
2293
|
+
idx &= area >= minarea
|
|
2294
|
+
|
|
2295
|
+
# S/N filter
|
|
2296
|
+
if sn > 0:
|
|
2297
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
2298
|
+
obj_sn = flux / fluxerr
|
|
2299
|
+
idx &= obj_sn > sn
|
|
2300
|
+
|
|
2301
|
+
# Positive flux filter
|
|
2302
|
+
idx &= flux > 0
|
|
2303
|
+
|
|
2304
|
+
# Apply filters
|
|
2305
|
+
n_before = len(obj)
|
|
2306
|
+
obj = obj[idx]
|
|
2307
|
+
|
|
2308
|
+
# Update segmentation map if needed
|
|
2309
|
+
if method == 'segmentation' and get_segmentation and segm is not None:
|
|
2310
|
+
# Filter segmentation to keep only selected sources
|
|
2311
|
+
# Note: This keeps the original segmentation but user can cross-reference with obj table
|
|
2312
|
+
pass
|
|
2313
|
+
|
|
2314
|
+
log(f'Filtered {n_before} -> {len(obj)} sources')
|
|
2315
|
+
|
|
2316
|
+
# Sort by brightness (flux, descending)
|
|
2317
|
+
if len(obj) > 0:
|
|
2318
|
+
obj.sort('flux', reverse=True)
|
|
2319
|
+
|
|
2320
|
+
# Add RA/Dec if WCS available
|
|
2321
|
+
if wcs is not None and wcs.has_celestial:
|
|
2322
|
+
try:
|
|
2323
|
+
coords = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
2324
|
+
obj['ra'] = coords[0]
|
|
2325
|
+
obj['dec'] = coords[1]
|
|
2326
|
+
log('Added WCS coordinates (RA, Dec)')
|
|
2327
|
+
except Exception as e:
|
|
2328
|
+
log(f'Warning: WCS conversion failed: {e}')
|
|
2329
|
+
|
|
2330
|
+
log(f'Returning {len(obj)} sources')
|
|
2331
|
+
|
|
2332
|
+
# Return results
|
|
2333
|
+
if get_segmentation:
|
|
2334
|
+
if method == 'segmentation':
|
|
2335
|
+
return obj, segm.data
|
|
2336
|
+
else:
|
|
2337
|
+
log('Warning: get_segmentation=True but method is not segmentation')
|
|
2338
|
+
return obj, None
|
|
2339
|
+
else:
|
|
2340
|
+
return obj
|