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
|
@@ -0,0 +1,2268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aperture photometry measurement routines.
|
|
3
|
+
|
|
4
|
+
This module contains functions for performing aperture photometry
|
|
5
|
+
on detected objects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import photutils
|
|
10
|
+
import photutils.background
|
|
11
|
+
import photutils.aperture
|
|
12
|
+
import photutils.centroids
|
|
13
|
+
import photutils.psf
|
|
14
|
+
from photutils.utils import calc_total_error
|
|
15
|
+
|
|
16
|
+
# Note: psf and photometry_psf modules imported lazily in functions to avoid circular dependency
|
|
17
|
+
|
|
18
|
+
# SEP-X is our rebranded SEP build carrying the optimal-extraction / PSF-fitting
|
|
19
|
+
# extras. It is imported under the plain ``sep`` name to keep call sites unchanged;
|
|
20
|
+
# a successful import is what gates the extra feature set.
|
|
21
|
+
try:
|
|
22
|
+
import sep_x as sep
|
|
23
|
+
|
|
24
|
+
_HAS_SEP_OPTIMAL = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
# Upstream SEP still covers the basic aperture/background paths; the
|
|
27
|
+
# optimal-extraction / PSF-fitting extras are simply unavailable.
|
|
28
|
+
import sep
|
|
29
|
+
|
|
30
|
+
_HAS_SEP_OPTIMAL = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_callable_fwhm(fwhm):
|
|
34
|
+
"""True if ``fwhm`` is a position-dependent callable (e.g. FWHMMap)."""
|
|
35
|
+
return callable(fwhm)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fwhm_scalar(fwhm):
|
|
39
|
+
"""Scalar FWHM summary (median for callables via ``float()``).
|
|
40
|
+
|
|
41
|
+
Returns ``None`` when ``fwhm`` is missing, non-positive, or not convertible.
|
|
42
|
+
"""
|
|
43
|
+
if fwhm is None:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
s = float(fwhm)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return None
|
|
49
|
+
if not np.isfinite(s) or s <= 0:
|
|
50
|
+
return None
|
|
51
|
+
return s
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fwhm_at(fwhm, x, y):
|
|
55
|
+
"""Evaluate FWHM at positions ``x``/``y``.
|
|
56
|
+
|
|
57
|
+
Returns a scalar for numeric ``fwhm``, an array for callable ``fwhm``,
|
|
58
|
+
and ``None`` when ``fwhm`` is missing or non-positive.
|
|
59
|
+
"""
|
|
60
|
+
if _is_callable_fwhm(fwhm):
|
|
61
|
+
return np.asarray(fwhm(x, y), dtype=float)
|
|
62
|
+
return _fwhm_scalar(fwhm)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fwhm_median(fwhm, image_shape=None):
|
|
66
|
+
"""Scalar summary of a FWHM specification (numeric or callable).
|
|
67
|
+
|
|
68
|
+
Uses ``float(fwhm)`` when available (e.g. FWHMMap exposes its median that
|
|
69
|
+
way); a generic callable without ``__float__`` is evaluated at the image
|
|
70
|
+
centre instead. Returns ``None`` when no usable summary can be produced.
|
|
71
|
+
"""
|
|
72
|
+
s = _fwhm_scalar(fwhm)
|
|
73
|
+
if s is not None:
|
|
74
|
+
return s
|
|
75
|
+
if _is_callable_fwhm(fwhm) and image_shape is not None:
|
|
76
|
+
H, W = image_shape[:2]
|
|
77
|
+
s = float(np.median(np.atleast_1d(fwhm(0.5 * W, 0.5 * H))))
|
|
78
|
+
if np.isfinite(s) and s > 0:
|
|
79
|
+
return s
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _extract_valid_positions(obj):
|
|
84
|
+
"""Extract object positions as plain arrays with validity mask.
|
|
85
|
+
|
|
86
|
+
Handles MaskedColumn inputs by filling masked values with NaN.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
obj : astropy.table.Table
|
|
91
|
+
Table with 'x' and 'y' columns.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
x_vals : ndarray
|
|
96
|
+
X positions with masked values filled as NaN.
|
|
97
|
+
y_vals : ndarray
|
|
98
|
+
Y positions with masked values filled as NaN.
|
|
99
|
+
valid_pos : ndarray of bool
|
|
100
|
+
True where both x and y are finite.
|
|
101
|
+
"""
|
|
102
|
+
x_vals = np.ma.filled(np.asarray(obj['x']), fill_value=np.nan)
|
|
103
|
+
y_vals = np.ma.filled(np.asarray(obj['y']), fill_value=np.nan)
|
|
104
|
+
valid_pos = np.isfinite(x_vals) & np.isfinite(y_vals)
|
|
105
|
+
return x_vals, y_vals, valid_pos
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _prepare_image_and_mask(image, mask):
|
|
109
|
+
"""Sanitize image and mask for photometry.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
image : ndarray
|
|
114
|
+
Input image array.
|
|
115
|
+
mask : ndarray of bool or None
|
|
116
|
+
Optional boolean mask (True = masked).
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
image1 : ndarray
|
|
121
|
+
Float64 copy of the image.
|
|
122
|
+
mask0 : ndarray of bool
|
|
123
|
+
Mask of non-finite pixels.
|
|
124
|
+
mask : ndarray of bool
|
|
125
|
+
Boolean mask array (zeros if input was None).
|
|
126
|
+
"""
|
|
127
|
+
image1 = image.astype(np.double)
|
|
128
|
+
mask0 = ~np.isfinite(image1)
|
|
129
|
+
|
|
130
|
+
if mask is None:
|
|
131
|
+
mask = np.zeros(image.shape, dtype=bool)
|
|
132
|
+
else:
|
|
133
|
+
mask = np.array(mask).astype(bool)
|
|
134
|
+
|
|
135
|
+
return image1, mask0, mask
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compute_magnitudes_and_filter(obj, sn, keep_negative, log):
|
|
139
|
+
"""Compute magnitudes from flux and apply S/N and positivity filters.
|
|
140
|
+
|
|
141
|
+
Modifies obj in-place to add 'mag' and 'magerr' columns, then
|
|
142
|
+
optionally returns a filtered subset.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
obj : astropy.table.Table
|
|
147
|
+
Table with 'flux' and 'fluxerr' columns.
|
|
148
|
+
sn : float or None
|
|
149
|
+
Minimum S/N ratio. None to skip filtering.
|
|
150
|
+
keep_negative : bool
|
|
151
|
+
If False, discard negative-flux measurements.
|
|
152
|
+
log : callable
|
|
153
|
+
Logging function.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
obj : astropy.table.Table
|
|
158
|
+
Possibly filtered table with 'mag' and 'magerr' columns set.
|
|
159
|
+
"""
|
|
160
|
+
for col in ['mag', 'magerr']:
|
|
161
|
+
obj[col] = np.nan
|
|
162
|
+
|
|
163
|
+
idx = obj['flux'] > 0
|
|
164
|
+
obj['mag'][idx] = -2.5 * np.log10(obj['flux'][idx])
|
|
165
|
+
obj['magerr'][idx] = 2.5 / np.log(10) * obj['fluxerr'][idx] / obj['flux'][idx]
|
|
166
|
+
|
|
167
|
+
if sn is not None and sn > 0:
|
|
168
|
+
log('Filtering out measurements with S/N < %.1f' % sn)
|
|
169
|
+
idx = np.isfinite(obj['magerr'])
|
|
170
|
+
idx[idx] &= obj['magerr'][idx] < 1 / sn
|
|
171
|
+
obj = obj[idx]
|
|
172
|
+
|
|
173
|
+
if not keep_negative:
|
|
174
|
+
log('Filtering out measurements with negative fluxes')
|
|
175
|
+
idx = obj['flux'] > 0
|
|
176
|
+
obj = obj[idx]
|
|
177
|
+
|
|
178
|
+
return obj
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _get_psf_stamp_at_position(psf, x, y, stamp_size=None):
|
|
182
|
+
"""Get normalized PSF stamp at a given position with automatic sub-pixel alignment.
|
|
183
|
+
|
|
184
|
+
For Gaussian PSF: Creates pixel-integrated PSF using the error function (erf)
|
|
185
|
+
to properly integrate Gaussian flux over each pixel's area. This eliminates
|
|
186
|
+
the FWHM-dependent systematic bias caused by point sampling at pixel centers:
|
|
187
|
+
- FWHM = 1.5 pix: +11.6% bias -> ~0%
|
|
188
|
+
- FWHM = 3.0 pix: +2.7% bias -> ~0%
|
|
189
|
+
- FWHM = 6.0 pix: +0.8% bias -> ~0%
|
|
190
|
+
|
|
191
|
+
The stamp is shifted by the sub-pixel offset (x - round(x), y - round(y))
|
|
192
|
+
to align with the actual source position.
|
|
193
|
+
|
|
194
|
+
For PSFEx/ePSF models: The psf.get_psf_stamp() function automatically applies
|
|
195
|
+
the same sub-pixel shift internally, so no additional correction is needed here.
|
|
196
|
+
Both methods produce identically aligned PSF stamps.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
psf : dict or float
|
|
201
|
+
PSF model (dict from PSFEx/ePSF, or Gaussian FWHM as float).
|
|
202
|
+
x : float
|
|
203
|
+
Object x position.
|
|
204
|
+
y : float
|
|
205
|
+
Object y position.
|
|
206
|
+
stamp_size : int or None
|
|
207
|
+
Optional fixed stamp size (odd integer).
|
|
208
|
+
|
|
209
|
+
Returns
|
|
210
|
+
-------
|
|
211
|
+
psf_stamp : ndarray
|
|
212
|
+
Normalized PSF stamp aligned to sub-pixel position (x, y).
|
|
213
|
+
|
|
214
|
+
Notes
|
|
215
|
+
-----
|
|
216
|
+
Technical details of pixel-integrated Gaussian:
|
|
217
|
+
|
|
218
|
+
- Uses erf (error function = CDF of Gaussian) to integrate over pixel boundaries
|
|
219
|
+
- Each pixel spans from (i-0.5, i+0.5) in both x and y
|
|
220
|
+
- Flux in pixel (i,j) = integral of Gaussian(x,y) dx dy over pixel area
|
|
221
|
+
- This is computed as: [CDF(x+0.5) - CDF(x-0.5)] * [CDF(y+0.5) - CDF(y-0.5)]
|
|
222
|
+
"""
|
|
223
|
+
if isinstance(psf, dict):
|
|
224
|
+
# PSFEx or ePSF model - lazy import to avoid circular dependency
|
|
225
|
+
from stdpipe import psf as psf_module
|
|
226
|
+
|
|
227
|
+
psf_stamp = psf_module.get_psf_stamp(psf, x=x, y=y, normalize=True)
|
|
228
|
+
else:
|
|
229
|
+
# Gaussian PSF - create stamp from FWHM with sub-pixel shift
|
|
230
|
+
# Use pixel integration to eliminate FWHM-dependent systematic bias
|
|
231
|
+
from scipy.special import erf
|
|
232
|
+
|
|
233
|
+
fwhm = float(psf)
|
|
234
|
+
sigma = fwhm / (2 * np.sqrt(2 * np.log(2)))
|
|
235
|
+
size = stamp_size if stamp_size else int(np.ceil(fwhm * 3)) * 2 + 1
|
|
236
|
+
|
|
237
|
+
# CRITICAL: Shift PSF by sub-pixel offset to align with actual source position
|
|
238
|
+
# This eliminates position-dependent biases:
|
|
239
|
+
# - Centroiding bias: ~0.2 pix → ~0.01 pix (20× improvement)
|
|
240
|
+
# - Optimal extraction flux bias from sub-pixel position: eliminated
|
|
241
|
+
ix, iy = int(np.round(x)), int(np.round(y))
|
|
242
|
+
dx_sub = x - ix # Sub-pixel offset (e.g., 0.3 if x=25.3)
|
|
243
|
+
dy_sub = y - iy
|
|
244
|
+
|
|
245
|
+
# PSF center in stamp coordinates (accounting for sub-pixel shift)
|
|
246
|
+
x0 = size // 2 + dx_sub
|
|
247
|
+
y0 = size // 2 + dy_sub
|
|
248
|
+
|
|
249
|
+
# Pixel edges: pixels span from (i-0.5) to (i+0.5)
|
|
250
|
+
x_edges = np.arange(size + 1) - 0.5
|
|
251
|
+
y_edges = np.arange(size + 1) - 0.5
|
|
252
|
+
|
|
253
|
+
# Integrate Gaussian over pixel boundaries using error function
|
|
254
|
+
# erf(z) = (2/√π) ∫₀^z exp(-t²) dt is the CDF of standard normal
|
|
255
|
+
# CDF of Gaussian N(μ,σ²) at point x is: Φ(x) = 0.5 * [1 + erf((x-μ)/(σ√2))]
|
|
256
|
+
sqrt2_sigma = np.sqrt(2) * sigma
|
|
257
|
+
cdf_x = 0.5 * (1 + erf((x_edges - x0) / sqrt2_sigma))
|
|
258
|
+
cdf_y = 0.5 * (1 + erf((y_edges - y0) / sqrt2_sigma))
|
|
259
|
+
|
|
260
|
+
# Flux in each pixel = CDF at right edge - CDF at left edge
|
|
261
|
+
# For pixel i: flux = CDF(i+0.5) - CDF(i-0.5)
|
|
262
|
+
flux_x = np.diff(cdf_x) # Integrated flux in x direction
|
|
263
|
+
flux_y = np.diff(cdf_y) # Integrated flux in y direction
|
|
264
|
+
|
|
265
|
+
# 2D PSF is separable: outer product of 1D integrated profiles
|
|
266
|
+
psf_stamp = np.outer(flux_y, flux_x)
|
|
267
|
+
|
|
268
|
+
# Normalize to unit sum
|
|
269
|
+
psf_stamp /= np.sum(psf_stamp)
|
|
270
|
+
|
|
271
|
+
return psf_stamp
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _psf_centroid(image, x, y, psf, mask=None, box_size=None, maxiter=16, tol=1e-4):
|
|
275
|
+
"""Compute PSF-weighted centroid at a given position.
|
|
276
|
+
|
|
277
|
+
Uses PSF weighting analogous to optimal extraction (Naylor 1998),
|
|
278
|
+
which naturally emphasizes high-S/N pixels in the PSF core.
|
|
279
|
+
This provides better stability and accuracy than simple center-of-mass,
|
|
280
|
+
especially for crowded fields and faint sources.
|
|
281
|
+
|
|
282
|
+
Iterates internally until convergence (position shift < tol) or maxiter
|
|
283
|
+
is reached. Due to the I*P weighting, each iteration only recovers half
|
|
284
|
+
the remaining offset, so ~10-16 iterations are needed for sub-0.01 pix
|
|
285
|
+
accuracy from a cold start.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
image : ndarray
|
|
290
|
+
Background-subtracted image.
|
|
291
|
+
x : float
|
|
292
|
+
Initial x position estimate.
|
|
293
|
+
y : float
|
|
294
|
+
Initial y position estimate.
|
|
295
|
+
psf : dict or float
|
|
296
|
+
PSF model (dict from PSFEx/ePSF, or Gaussian FWHM as float).
|
|
297
|
+
mask : ndarray of bool or None
|
|
298
|
+
Optional mask (True = masked).
|
|
299
|
+
box_size : int or None
|
|
300
|
+
Minimum cutout size in pixels (actual size is max of this and PSF stamp size).
|
|
301
|
+
maxiter : int
|
|
302
|
+
Maximum number of internal iterations.
|
|
303
|
+
tol : float
|
|
304
|
+
Convergence tolerance in pixels.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
x_new : float
|
|
309
|
+
Refined x position, or np.nan on failure.
|
|
310
|
+
y_new : float
|
|
311
|
+
Refined y position, or np.nan on failure.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
# Determine box_size from PSF stamp size on the first call
|
|
315
|
+
psf_stamp = _get_psf_stamp_at_position(psf, x, y)
|
|
316
|
+
stamp_size = psf_stamp.shape[0]
|
|
317
|
+
|
|
318
|
+
if box_size is None:
|
|
319
|
+
box_size = stamp_size
|
|
320
|
+
else:
|
|
321
|
+
box_size = max(box_size, stamp_size)
|
|
322
|
+
if box_size % 2 == 0:
|
|
323
|
+
box_size += 1
|
|
324
|
+
|
|
325
|
+
half = box_size // 2
|
|
326
|
+
|
|
327
|
+
for iteration in range(maxiter):
|
|
328
|
+
# Integer position for cutout center
|
|
329
|
+
ix, iy = int(np.round(x)), int(np.round(y))
|
|
330
|
+
|
|
331
|
+
# Extract cutout
|
|
332
|
+
y0, y1 = iy - half, iy + half + 1
|
|
333
|
+
x0, x1 = ix - half, ix + half + 1
|
|
334
|
+
|
|
335
|
+
if y0 < 0 or x0 < 0 or y1 > image.shape[0] or x1 > image.shape[1]:
|
|
336
|
+
return np.nan, np.nan
|
|
337
|
+
|
|
338
|
+
data_cutout = image[y0:y1, x0:x1]
|
|
339
|
+
|
|
340
|
+
# Keep negative (background-subtracted noise) pixels: excluding them
|
|
341
|
+
# biases faint-source centroids towards positive noise fluctuations
|
|
342
|
+
if mask is not None:
|
|
343
|
+
mask_cutout = mask[y0:y1, x0:x1]
|
|
344
|
+
good = ~mask_cutout & np.isfinite(data_cutout)
|
|
345
|
+
else:
|
|
346
|
+
good = np.isfinite(data_cutout)
|
|
347
|
+
|
|
348
|
+
if np.sum(good) < 3:
|
|
349
|
+
return np.nan, np.nan
|
|
350
|
+
|
|
351
|
+
# Get PSF stamp at current position (shifted by sub-pixel offset)
|
|
352
|
+
psf_stamp = _get_psf_stamp_at_position(psf, x, y)
|
|
353
|
+
|
|
354
|
+
# Embed PSF stamp in box-sized array if needed
|
|
355
|
+
if psf_stamp.shape[0] != box_size:
|
|
356
|
+
psf_resized = np.zeros((box_size, box_size))
|
|
357
|
+
psf_half = psf_stamp.shape[0] // 2
|
|
358
|
+
box_half = box_size // 2
|
|
359
|
+
y_start = box_half - psf_half
|
|
360
|
+
x_start = box_half - psf_half
|
|
361
|
+
psf_resized[
|
|
362
|
+
y_start : y_start + psf_stamp.shape[0], x_start : x_start + psf_stamp.shape[1]
|
|
363
|
+
] = psf_stamp
|
|
364
|
+
psf_stamp = psf_resized
|
|
365
|
+
|
|
366
|
+
# Coordinate grids in image coordinates
|
|
367
|
+
yy, xx = np.mgrid[y0:y1, x0:x1]
|
|
368
|
+
|
|
369
|
+
I = data_cutout[good]
|
|
370
|
+
P = psf_stamp[good]
|
|
371
|
+
|
|
372
|
+
# Offsets from current floating-point position
|
|
373
|
+
x_off = xx[good].astype(float) - x
|
|
374
|
+
y_off = yy[good].astype(float) - y
|
|
375
|
+
|
|
376
|
+
sum_IP = np.sum(I * P)
|
|
377
|
+
if sum_IP <= 0:
|
|
378
|
+
return np.nan, np.nan
|
|
379
|
+
|
|
380
|
+
dx = np.sum(x_off * I * P) / sum_IP
|
|
381
|
+
dy = np.sum(y_off * I * P) / sum_IP
|
|
382
|
+
|
|
383
|
+
x += dx
|
|
384
|
+
y += dy
|
|
385
|
+
|
|
386
|
+
if dx * dx + dy * dy < tol * tol:
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
return x, y
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _solve_weighted_leastsq(A, D, W):
|
|
393
|
+
"""Solve weighted least squares: (A^T W A)x = A^T W D.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
A : ndarray
|
|
398
|
+
Design matrix, shape (npix, K) where K is number of sources.
|
|
399
|
+
D : ndarray
|
|
400
|
+
Data vector, shape (npix,).
|
|
401
|
+
W : ndarray
|
|
402
|
+
Weight vector, shape (npix,), inverse variance weights.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
x : ndarray or None
|
|
407
|
+
Solution vector, or None on failure.
|
|
408
|
+
cov : ndarray or None
|
|
409
|
+
Covariance matrix, or None on failure.
|
|
410
|
+
"""
|
|
411
|
+
# A^T W A and A^T W D using weight vector
|
|
412
|
+
# Near-singular systems (e.g. overlapping sources) can produce
|
|
413
|
+
# non-finite intermediates — catch and return failure gracefully
|
|
414
|
+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
|
|
415
|
+
AtW = A.T * W # Broadcasting: (K, npix) * (npix,) -> (K, npix)
|
|
416
|
+
AtWA = AtW @ A # (K, K)
|
|
417
|
+
AtWD = AtW @ D # (K,)
|
|
418
|
+
|
|
419
|
+
if not np.all(np.isfinite(AtWA)) or not np.all(np.isfinite(AtWD)):
|
|
420
|
+
return None, None
|
|
421
|
+
|
|
422
|
+
# Solve and get covariance
|
|
423
|
+
try:
|
|
424
|
+
x = np.linalg.solve(AtWA, AtWD)
|
|
425
|
+
if not np.all(np.isfinite(x)):
|
|
426
|
+
return None, None
|
|
427
|
+
cov = np.linalg.solve(AtWA, np.eye(AtWA.shape[0]))
|
|
428
|
+
except np.linalg.LinAlgError:
|
|
429
|
+
return None, None
|
|
430
|
+
|
|
431
|
+
return x, cov
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _grouped_optimal_extraction(image, err, positions, psf, bg_local=None, mask=None, radius=None):
|
|
435
|
+
"""Perform grouped optimal extraction for multiple overlapping sources.
|
|
436
|
+
|
|
437
|
+
Solves D = sum_k F_k * P_k + B via weighted least squares (A^T W A)x = A^T W D.
|
|
438
|
+
Simultaneously fits the fluxes of all sources in a group, properly
|
|
439
|
+
accounting for flux sharing between overlapping PSFs.
|
|
440
|
+
|
|
441
|
+
Parameters
|
|
442
|
+
----------
|
|
443
|
+
image : ndarray
|
|
444
|
+
Background-subtracted image.
|
|
445
|
+
err : ndarray
|
|
446
|
+
Error/noise map (sqrt of variance).
|
|
447
|
+
positions : list of tuple
|
|
448
|
+
List of (x, y) positions for sources in group.
|
|
449
|
+
psf : dict or float
|
|
450
|
+
PSF model (dict from PSFEx/ePSF, or Gaussian FWHM as float).
|
|
451
|
+
bg_local : list, ndarray, float, or None
|
|
452
|
+
Local background values for each source, or single value for all.
|
|
453
|
+
mask : ndarray of bool or None
|
|
454
|
+
Optional mask (True = masked).
|
|
455
|
+
radius : float or None
|
|
456
|
+
Extraction radius in pixels around each source.
|
|
457
|
+
|
|
458
|
+
Returns
|
|
459
|
+
-------
|
|
460
|
+
results : list of tuple
|
|
461
|
+
List of (flux, fluxerr, npix, norm, reduced_chi2) for each source.
|
|
462
|
+
"""
|
|
463
|
+
K = len(positions)
|
|
464
|
+
if K == 0:
|
|
465
|
+
return []
|
|
466
|
+
|
|
467
|
+
# For single source, use standard optimal extraction
|
|
468
|
+
if K == 1:
|
|
469
|
+
x, y = positions[0]
|
|
470
|
+
bg = bg_local[0] if isinstance(bg_local, (list, np.ndarray)) else bg_local
|
|
471
|
+
result = _optimal_extraction(image, err, x, y, psf, bg_local=bg, mask=mask, radius=radius)
|
|
472
|
+
return [result]
|
|
473
|
+
|
|
474
|
+
# Get PSF stamp size from first position
|
|
475
|
+
test_stamp = _get_psf_stamp_at_position(psf, positions[0][0], positions[0][1])
|
|
476
|
+
stamp_size = test_stamp.shape[0]
|
|
477
|
+
half = stamp_size // 2
|
|
478
|
+
|
|
479
|
+
# Determine bounding box covering all sources + PSF radius
|
|
480
|
+
xs = np.array([p[0] for p in positions])
|
|
481
|
+
ys = np.array([p[1] for p in positions])
|
|
482
|
+
|
|
483
|
+
# Integer bounds with padding for PSF
|
|
484
|
+
x0 = int(np.floor(np.min(xs))) - half
|
|
485
|
+
x1 = int(np.ceil(np.max(xs))) + half + 1
|
|
486
|
+
y0 = int(np.floor(np.min(ys))) - half
|
|
487
|
+
y1 = int(np.ceil(np.max(ys))) + half + 1
|
|
488
|
+
|
|
489
|
+
# Handle image boundaries
|
|
490
|
+
if x0 < 0 or y0 < 0 or x1 > image.shape[1] or y1 > image.shape[0]:
|
|
491
|
+
# Fall back to individual fitting for edge groups
|
|
492
|
+
results = []
|
|
493
|
+
for i, (x, y) in enumerate(positions):
|
|
494
|
+
bg = bg_local[i] if isinstance(bg_local, (list, np.ndarray)) else bg_local
|
|
495
|
+
results.append(
|
|
496
|
+
_optimal_extraction(image, err, x, y, psf, bg_local=bg, mask=mask, radius=radius)
|
|
497
|
+
)
|
|
498
|
+
return results
|
|
499
|
+
|
|
500
|
+
# Extract common cutout
|
|
501
|
+
data_cutout = image[y0:y1, x0:x1].copy()
|
|
502
|
+
err_cutout = err[y0:y1, x0:x1]
|
|
503
|
+
cutout_shape = data_cutout.shape
|
|
504
|
+
|
|
505
|
+
# Build mask for good pixels
|
|
506
|
+
if mask is not None:
|
|
507
|
+
mask_cutout = mask[y0:y1, x0:x1]
|
|
508
|
+
good = ~mask_cutout & (err_cutout > 0) & np.isfinite(data_cutout)
|
|
509
|
+
else:
|
|
510
|
+
good = (err_cutout > 0) & np.isfinite(data_cutout)
|
|
511
|
+
|
|
512
|
+
# Apply radius cutoff around each source if specified
|
|
513
|
+
if radius is not None:
|
|
514
|
+
yy, xx = np.mgrid[0 : cutout_shape[0], 0 : cutout_shape[1]]
|
|
515
|
+
radius_mask = np.zeros(cutout_shape, dtype=bool)
|
|
516
|
+
for x, y in positions:
|
|
517
|
+
# Position relative to cutout
|
|
518
|
+
rel_x = x - x0
|
|
519
|
+
rel_y = y - y0
|
|
520
|
+
dist = np.sqrt((xx - rel_x) ** 2 + (yy - rel_y) ** 2)
|
|
521
|
+
radius_mask |= dist <= radius
|
|
522
|
+
good &= radius_mask
|
|
523
|
+
|
|
524
|
+
n_pix = int(np.sum(good))
|
|
525
|
+
if n_pix == 0:
|
|
526
|
+
return [(np.nan, np.nan, 0, np.nan, np.nan) for _ in range(K)]
|
|
527
|
+
|
|
528
|
+
# Subtract average local background to center data (fit includes residual background term)
|
|
529
|
+
bg_offset = 0.0
|
|
530
|
+
if bg_local is not None:
|
|
531
|
+
if isinstance(bg_local, (list, np.ndarray)):
|
|
532
|
+
bg_offset = np.nanmean(bg_local)
|
|
533
|
+
else:
|
|
534
|
+
bg_offset = bg_local
|
|
535
|
+
if np.isfinite(bg_offset) and bg_offset != 0:
|
|
536
|
+
data_cutout -= bg_offset
|
|
537
|
+
|
|
538
|
+
# Build design matrix with K PSF columns (+ optional background term)
|
|
539
|
+
# Only fit background if bg_local was provided (i.e., we subtracted source-specific backgrounds)
|
|
540
|
+
fit_background = bg_local is not None and np.isfinite(bg_offset) and bg_offset != 0
|
|
541
|
+
n_params = K + 1 if fit_background else K
|
|
542
|
+
A = np.zeros((n_pix, n_params))
|
|
543
|
+
psf_norms = np.zeros(K)
|
|
544
|
+
|
|
545
|
+
for k, (x, y) in enumerate(positions):
|
|
546
|
+
# Get PSF at this source position
|
|
547
|
+
psf_stamp = _get_psf_stamp_at_position(psf, x, y, stamp_size)
|
|
548
|
+
|
|
549
|
+
# Position of PSF center relative to cutout origin
|
|
550
|
+
rel_x = x - x0
|
|
551
|
+
rel_y = y - y0
|
|
552
|
+
|
|
553
|
+
# Integer position and sub-pixel offset
|
|
554
|
+
ix_rel = int(np.round(rel_x))
|
|
555
|
+
iy_rel = int(np.round(rel_y))
|
|
556
|
+
|
|
557
|
+
# PSF stamp placement bounds in cutout
|
|
558
|
+
p_y0 = iy_rel - half
|
|
559
|
+
p_y1 = iy_rel + half + 1
|
|
560
|
+
p_x0 = ix_rel - half
|
|
561
|
+
p_x1 = ix_rel + half + 1
|
|
562
|
+
|
|
563
|
+
# Create full-size PSF array for this source
|
|
564
|
+
psf_full = np.zeros(cutout_shape)
|
|
565
|
+
|
|
566
|
+
# Handle partial overlap at cutout edges
|
|
567
|
+
# Source region in cutout
|
|
568
|
+
c_y0 = max(0, p_y0)
|
|
569
|
+
c_y1 = min(cutout_shape[0], p_y1)
|
|
570
|
+
c_x0 = max(0, p_x0)
|
|
571
|
+
c_x1 = min(cutout_shape[1], p_x1)
|
|
572
|
+
|
|
573
|
+
# Corresponding region in PSF stamp
|
|
574
|
+
s_y0 = c_y0 - p_y0
|
|
575
|
+
s_y1 = stamp_size - (p_y1 - c_y1)
|
|
576
|
+
s_x0 = c_x0 - p_x0
|
|
577
|
+
s_x1 = stamp_size - (p_x1 - c_x1)
|
|
578
|
+
|
|
579
|
+
if c_y1 > c_y0 and c_x1 > c_x0:
|
|
580
|
+
psf_full[c_y0:c_y1, c_x0:c_x1] = psf_stamp[s_y0:s_y1, s_x0:s_x1]
|
|
581
|
+
|
|
582
|
+
A[:, k] = psf_full[good]
|
|
583
|
+
psf_norms[k] = np.sum(psf_full[good])
|
|
584
|
+
|
|
585
|
+
# Common background term (only if we subtracted varying local backgrounds)
|
|
586
|
+
if fit_background:
|
|
587
|
+
A[:, -1] = 1.0
|
|
588
|
+
|
|
589
|
+
# Data and weights
|
|
590
|
+
D = data_cutout[good]
|
|
591
|
+
V = err_cutout[good] ** 2
|
|
592
|
+
|
|
593
|
+
# Use simplified weighting (Naylor 1998) to match single-source behavior:
|
|
594
|
+
# Instead of full variance weighting W=1/V, use W=1 (unweighted least squares)
|
|
595
|
+
# This makes grouped and single-source give identical results
|
|
596
|
+
W = np.ones_like(V)
|
|
597
|
+
|
|
598
|
+
# Solve unweighted least squares (for flux consistency with single-source)
|
|
599
|
+
x_sol, cov = _solve_weighted_leastsq(A, D, W)
|
|
600
|
+
|
|
601
|
+
# Propagate per-pixel variance through the unweighted solution via the
|
|
602
|
+
# sandwich formula: cov = (AᵀA)⁻¹ Aᵀ diag(V) A (AᵀA)⁻¹. For a single
|
|
603
|
+
# source this reduces exactly to ΣP²V/(ΣP²)², matching _optimal_extraction.
|
|
604
|
+
if x_sol is not None and cov is not None:
|
|
605
|
+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
|
|
606
|
+
AtVA = (A * V[:, np.newaxis]).T @ A
|
|
607
|
+
cov = cov @ AtVA @ cov
|
|
608
|
+
if not np.all(np.isfinite(cov)):
|
|
609
|
+
x_sol, cov = None, None
|
|
610
|
+
|
|
611
|
+
if x_sol is None:
|
|
612
|
+
# Matrix singularity - fall back to individual fitting
|
|
613
|
+
results = []
|
|
614
|
+
for i, (x, y) in enumerate(positions):
|
|
615
|
+
bg = bg_local[i] if isinstance(bg_local, (list, np.ndarray)) else bg_local
|
|
616
|
+
results.append(
|
|
617
|
+
_optimal_extraction(image, err, x, y, psf, bg_local=bg, mask=mask, radius=radius)
|
|
618
|
+
)
|
|
619
|
+
return results
|
|
620
|
+
|
|
621
|
+
# Extract fluxes and errors
|
|
622
|
+
fluxes = x_sol[:K]
|
|
623
|
+
flux_errors = np.sqrt(np.diag(cov)[:K])
|
|
624
|
+
|
|
625
|
+
# Compute chi-squared for the group fit
|
|
626
|
+
# Use variance weighting for chi2 (not W, which is 1 for unweighted fit)
|
|
627
|
+
# This gives the proper goodness-of-fit metric: χ² = Σ((data - model)² / variance)
|
|
628
|
+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
|
|
629
|
+
model = A @ x_sol
|
|
630
|
+
residuals = D - model
|
|
631
|
+
chi2 = np.sum(residuals**2 / V)
|
|
632
|
+
|
|
633
|
+
# Reduced chi-squared (degrees of freedom = N - n_params)
|
|
634
|
+
dof = n_pix - n_params
|
|
635
|
+
if dof > 0:
|
|
636
|
+
reduced_chi2 = chi2 / dof
|
|
637
|
+
else:
|
|
638
|
+
reduced_chi2 = np.nan
|
|
639
|
+
|
|
640
|
+
# Build results for each source
|
|
641
|
+
results = []
|
|
642
|
+
for k in range(K):
|
|
643
|
+
results.append((fluxes[k], flux_errors[k], n_pix, psf_norms[k], reduced_chi2))
|
|
644
|
+
|
|
645
|
+
return results
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _optimal_extraction(image, err, x, y, psf, bg_local=None, mask=None, radius=None):
|
|
649
|
+
"""Perform optimal extraction photometry at a single position.
|
|
650
|
+
|
|
651
|
+
Algorithm from Naylor (1998, MNRAS 296, 339) based on Horne (1986),
|
|
652
|
+
using simplified weighting for robustness to PSF errors.
|
|
653
|
+
|
|
654
|
+
flux = sum(P * D) / sum(P^2)
|
|
655
|
+
variance = sum(P^2 * V) / (sum(P^2))^2
|
|
656
|
+
|
|
657
|
+
Parameters
|
|
658
|
+
----------
|
|
659
|
+
image : ndarray
|
|
660
|
+
Background-subtracted image.
|
|
661
|
+
err : ndarray
|
|
662
|
+
Error/noise map (sqrt of variance).
|
|
663
|
+
x : float
|
|
664
|
+
Object x position.
|
|
665
|
+
y : float
|
|
666
|
+
Object y position.
|
|
667
|
+
psf : dict or float
|
|
668
|
+
PSF model (dict from PSFEx/ePSF, or Gaussian FWHM as float).
|
|
669
|
+
bg_local : float or None
|
|
670
|
+
Optional local background value to be additionally subtracted
|
|
671
|
+
prior to measurement.
|
|
672
|
+
mask : ndarray of bool or None
|
|
673
|
+
Optional mask (True = masked).
|
|
674
|
+
radius : float or None
|
|
675
|
+
Extraction radius in pixels (default: use full PSF stamp).
|
|
676
|
+
|
|
677
|
+
Returns
|
|
678
|
+
-------
|
|
679
|
+
flux : float
|
|
680
|
+
Extracted flux.
|
|
681
|
+
fluxerr : float
|
|
682
|
+
Flux uncertainty.
|
|
683
|
+
npix : int
|
|
684
|
+
Number of pixels used.
|
|
685
|
+
norm : float
|
|
686
|
+
PSF normalization factor.
|
|
687
|
+
reduced_chi2 : float
|
|
688
|
+
Reduced chi-squared of the fit.
|
|
689
|
+
"""
|
|
690
|
+
# Get PSF stamp at object position (automatically includes sub-pixel shift)
|
|
691
|
+
psf_stamp = _get_psf_stamp_at_position(psf, x, y)
|
|
692
|
+
|
|
693
|
+
# Determine extraction region
|
|
694
|
+
stamp_size = psf_stamp.shape[0]
|
|
695
|
+
half = stamp_size // 2
|
|
696
|
+
|
|
697
|
+
# Integer position for cutout
|
|
698
|
+
ix, iy = int(np.round(x)), int(np.round(y))
|
|
699
|
+
|
|
700
|
+
# Extract cutouts
|
|
701
|
+
y0, y1 = iy - half, iy + half + 1
|
|
702
|
+
x0, x1 = ix - half, ix + half + 1
|
|
703
|
+
|
|
704
|
+
# Handle edge cases (slice ends are exclusive, so == shape is still valid)
|
|
705
|
+
if y0 < 0 or x0 < 0 or y1 > image.shape[0] or x1 > image.shape[1]:
|
|
706
|
+
return np.nan, np.nan, 0, np.nan, np.nan
|
|
707
|
+
|
|
708
|
+
data_cutout = image[y0:y1, x0:x1]
|
|
709
|
+
err_cutout = err[y0:y1, x0:x1]
|
|
710
|
+
|
|
711
|
+
# Apply mask if provided
|
|
712
|
+
if mask is not None:
|
|
713
|
+
mask_cutout = mask[y0:y1, x0:x1]
|
|
714
|
+
good = ~mask_cutout & (err_cutout > 0) & np.isfinite(data_cutout)
|
|
715
|
+
else:
|
|
716
|
+
good = (err_cutout > 0) & np.isfinite(data_cutout)
|
|
717
|
+
|
|
718
|
+
# Apply radius cutoff if specified
|
|
719
|
+
if radius is not None:
|
|
720
|
+
yy, xx = np.mgrid[0:stamp_size, 0:stamp_size]
|
|
721
|
+
dist = np.sqrt((xx - half) ** 2 + (yy - half) ** 2)
|
|
722
|
+
good &= dist <= radius
|
|
723
|
+
|
|
724
|
+
if np.sum(good) == 0:
|
|
725
|
+
return np.nan, np.nan, 0, np.nan, np.nan
|
|
726
|
+
|
|
727
|
+
# Variance = err^2
|
|
728
|
+
var = err_cutout**2
|
|
729
|
+
|
|
730
|
+
# Optimal extraction with simplified weighting (Naylor 1998)
|
|
731
|
+
P = psf_stamp[good]
|
|
732
|
+
D = data_cutout[good]
|
|
733
|
+
V = var[good]
|
|
734
|
+
|
|
735
|
+
# Skip non-finite local background (failed annulus) rather than
|
|
736
|
+
# propagating NaN into the whole measurement
|
|
737
|
+
if bg_local is not None and np.isfinite(bg_local) and bg_local != 0:
|
|
738
|
+
D -= bg_local
|
|
739
|
+
|
|
740
|
+
# Simplified weighting: flux = Σ(P × D) / Σ(P²)
|
|
741
|
+
sum_P2 = np.sum(P**2)
|
|
742
|
+
|
|
743
|
+
if sum_P2 <= 0:
|
|
744
|
+
return np.nan, np.nan, 0, np.nan, np.nan
|
|
745
|
+
|
|
746
|
+
flux = np.sum(P * D) / sum_P2
|
|
747
|
+
|
|
748
|
+
# variance = Σ(P² × V) / (Σ(P²))²
|
|
749
|
+
variance = np.sum(P**2 * V) / (sum_P2**2)
|
|
750
|
+
fluxerr = np.sqrt(variance)
|
|
751
|
+
|
|
752
|
+
# Compute chi-squared: χ² = Σ((D - flux × P)² / V)
|
|
753
|
+
residuals = D - flux * P
|
|
754
|
+
chi2 = np.sum(residuals**2 / V)
|
|
755
|
+
|
|
756
|
+
# Reduced chi-squared (degrees of freedom = N - 1)
|
|
757
|
+
n_pix = int(np.sum(good))
|
|
758
|
+
if n_pix > 1:
|
|
759
|
+
reduced_chi2 = chi2 / (n_pix - 1)
|
|
760
|
+
else:
|
|
761
|
+
reduced_chi2 = np.nan
|
|
762
|
+
|
|
763
|
+
return flux, fluxerr, n_pix, np.sum(P), reduced_chi2
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def measure_objects(
|
|
767
|
+
obj,
|
|
768
|
+
image,
|
|
769
|
+
aper=3,
|
|
770
|
+
bkgann=None,
|
|
771
|
+
bkg_order=0,
|
|
772
|
+
fwhm=None,
|
|
773
|
+
psf=None,
|
|
774
|
+
optimal=False,
|
|
775
|
+
group_sources=True,
|
|
776
|
+
grouper_radius=None,
|
|
777
|
+
mask=None,
|
|
778
|
+
bg=None,
|
|
779
|
+
err=None,
|
|
780
|
+
gain=None,
|
|
781
|
+
bg_size=64,
|
|
782
|
+
sn=None,
|
|
783
|
+
centroid_iter=0,
|
|
784
|
+
centroid_method='com',
|
|
785
|
+
keep_negative=True,
|
|
786
|
+
get_bg=False,
|
|
787
|
+
verbose=False,
|
|
788
|
+
):
|
|
789
|
+
"""Photometry at the positions of already detected objects.
|
|
790
|
+
|
|
791
|
+
Supports both standard aperture photometry and optimal extraction that
|
|
792
|
+
provides ~10% S/N improvement for point sources (Naylor 1998).
|
|
793
|
+
|
|
794
|
+
It will estimate and subtract the background unless external background
|
|
795
|
+
estimation (``bg``) is provided, and use user-provided noise map (``err``)
|
|
796
|
+
if requested.
|
|
797
|
+
|
|
798
|
+
The results may optionally be filtered to drop detections with low signal
|
|
799
|
+
to noise ratio if ``sn`` is set and positive. It will also filter out
|
|
800
|
+
events with negative flux unless ``keep_negative`` is True.
|
|
801
|
+
|
|
802
|
+
Parameters
|
|
803
|
+
----------
|
|
804
|
+
obj : astropy.table.Table
|
|
805
|
+
Table with initial object detections to be measured.
|
|
806
|
+
image : ndarray
|
|
807
|
+
Input image as a NumPy array.
|
|
808
|
+
aper : float
|
|
809
|
+
Circular aperture radius in pixels for flux measurement. For optimal
|
|
810
|
+
extraction, this is the clipping radius.
|
|
811
|
+
bkgann : tuple of float or None
|
|
812
|
+
Background annulus (inner, outer radii) for local background
|
|
813
|
+
estimation. If None, global background model is used instead.
|
|
814
|
+
bkg_order : int
|
|
815
|
+
Polynomial order for local background fitting. 0 = constant (mean),
|
|
816
|
+
1 = plane (linear gradient, recommended), 2 = quadratic surface.
|
|
817
|
+
Only used if ``bkgann`` is set.
|
|
818
|
+
fwhm : float, callable, or None
|
|
819
|
+
If provided, ``aper`` and ``bkgann`` are measured in units of FWHM.
|
|
820
|
+
Also used to define Gaussian PSF for optimal extraction if ``psf``
|
|
821
|
+
is not provided. A callable (e.g. a
|
|
822
|
+
:class:`stdpipe.photometry.FWHMMap`) is accepted and evaluated at
|
|
823
|
+
each source position for the optimal-extraction PSF width;
|
|
824
|
+
aperture/bkgann scaling, PSF-weighted centroiding, and local
|
|
825
|
+
background annuli still use the scalar summary ``float(fwhm)``.
|
|
826
|
+
psf : dict or None
|
|
827
|
+
PSF model for optimal extraction and PSF-weighted centroiding. Can be
|
|
828
|
+
a dict from ``psf.run_psfex()``, ``psf.load_psf()``, or
|
|
829
|
+
``psf.create_psf_model()``. If None, a Gaussian PSF is created from
|
|
830
|
+
the ``fwhm`` parameter.
|
|
831
|
+
optimal : bool
|
|
832
|
+
If True, use optimal extraction instead of aperture photometry.
|
|
833
|
+
Requires either ``psf`` or ``fwhm`` to define the PSF profile.
|
|
834
|
+
group_sources : bool
|
|
835
|
+
If True and ``optimal=True``, use grouped optimal extraction for
|
|
836
|
+
overlapping sources. Fits nearby sources simultaneously for better
|
|
837
|
+
accuracy in crowded fields.
|
|
838
|
+
grouper_radius : float or None
|
|
839
|
+
Radius in pixels for grouping nearby sources. Sources within this
|
|
840
|
+
distance are fitted simultaneously. If None, defaults to 2*aper.
|
|
841
|
+
Only used if ``group_sources=True``.
|
|
842
|
+
mask : ndarray of bool or None
|
|
843
|
+
Image mask (True values are masked).
|
|
844
|
+
bg : ndarray or None
|
|
845
|
+
If provided, use this background instead of automatically computed
|
|
846
|
+
one. Must have the same shape as input image.
|
|
847
|
+
err : ndarray or None
|
|
848
|
+
Image noise map to use instead of automatically computed one.
|
|
849
|
+
gain : float or None
|
|
850
|
+
Image gain in e-/ADU, used to build image noise model.
|
|
851
|
+
bg_size : int
|
|
852
|
+
Background grid size in pixels.
|
|
853
|
+
sn : float or None
|
|
854
|
+
Minimal S/N ratio. If set, measurements with magnitude errors
|
|
855
|
+
exceeding 1/sn will be discarded.
|
|
856
|
+
centroid_iter : int
|
|
857
|
+
Number of centroiding iterations to run before photometry.
|
|
858
|
+
centroid_method : str
|
|
859
|
+
Centroiding method: 'com' (center-of-mass) or 'psf' (PSF-weighted).
|
|
860
|
+
PSF-weighted is more accurate but degrades with heavy random masking
|
|
861
|
+
(>20%). Requires ``psf`` or ``fwhm`` parameter.
|
|
862
|
+
keep_negative : bool
|
|
863
|
+
If False, measurements with negative fluxes will be discarded.
|
|
864
|
+
get_bg : bool
|
|
865
|
+
If True, also return estimated background and background noise images.
|
|
866
|
+
verbose : bool or callable
|
|
867
|
+
Whether to show verbose messages. May be boolean or a print-like
|
|
868
|
+
function.
|
|
869
|
+
|
|
870
|
+
Returns
|
|
871
|
+
-------
|
|
872
|
+
obj : astropy.table.Table
|
|
873
|
+
Copy of original table with ``flux``, ``fluxerr``, ``mag`` and
|
|
874
|
+
``magerr`` columns replaced with measured values.
|
|
875
|
+
bg_image : ndarray
|
|
876
|
+
Background image (only returned if ``get_bg=True``).
|
|
877
|
+
err_image : ndarray
|
|
878
|
+
Error image (only returned if ``get_bg=True``).
|
|
879
|
+
|
|
880
|
+
Notes
|
|
881
|
+
-----
|
|
882
|
+
Quality flags set in the ``flags`` column:
|
|
883
|
+
|
|
884
|
+
- 0x200: At least one aperture pixel is masked
|
|
885
|
+
- 0x400: Invalid position or local background estimation failed
|
|
886
|
+
- 0x800: Optimal extraction failed (NaN result)
|
|
887
|
+
- 0x1000: Poor fit quality (chi2 > 1000, numerical instability)
|
|
888
|
+
"""
|
|
889
|
+
|
|
890
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
891
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
892
|
+
|
|
893
|
+
if not len(obj):
|
|
894
|
+
log('No objects to measure')
|
|
895
|
+
return obj
|
|
896
|
+
|
|
897
|
+
# Operate on the copy of the list
|
|
898
|
+
obj = obj.copy()
|
|
899
|
+
|
|
900
|
+
image1, mask0, mask = _prepare_image_and_mask(image, mask)
|
|
901
|
+
|
|
902
|
+
if bg is None or err is None or get_bg:
|
|
903
|
+
log('Estimating global background with %dx%d grid' % (bg_size, bg_size))
|
|
904
|
+
bg_est = photutils.background.Background2D(
|
|
905
|
+
image1, bg_size, mask=mask | mask0, exclude_percentile=90
|
|
906
|
+
)
|
|
907
|
+
bg_est_bg = bg_est.background
|
|
908
|
+
bg_est_rms = bg_est.background_rms
|
|
909
|
+
else:
|
|
910
|
+
bg_est = None
|
|
911
|
+
|
|
912
|
+
if bg is None:
|
|
913
|
+
log(
|
|
914
|
+
'Subtracting global background: median %.1f rms %.2f'
|
|
915
|
+
% (np.median(bg_est_bg), np.std(bg_est_bg))
|
|
916
|
+
)
|
|
917
|
+
image1 -= bg_est_bg
|
|
918
|
+
else:
|
|
919
|
+
log(
|
|
920
|
+
'Subtracting user-provided background: median %.1f rms %.2f'
|
|
921
|
+
% (np.median(bg), np.std(bg))
|
|
922
|
+
)
|
|
923
|
+
image1 -= bg
|
|
924
|
+
|
|
925
|
+
image1[mask0] = 0
|
|
926
|
+
|
|
927
|
+
if err is None:
|
|
928
|
+
log(
|
|
929
|
+
'Using global background noise map: median %.1f rms %.2f + gain %.1f'
|
|
930
|
+
% (
|
|
931
|
+
np.median(bg_est_rms),
|
|
932
|
+
np.std(bg_est_rms),
|
|
933
|
+
gain if gain else np.inf,
|
|
934
|
+
)
|
|
935
|
+
)
|
|
936
|
+
err = bg_est_rms
|
|
937
|
+
if gain:
|
|
938
|
+
err = calc_total_error(image1, err, gain)
|
|
939
|
+
else:
|
|
940
|
+
log('Using user-provided noise map: median %.1f rms %.2f' % (np.median(err), np.std(err)))
|
|
941
|
+
|
|
942
|
+
fwhm_s = _fwhm_median(fwhm, image.shape)
|
|
943
|
+
fwhm_is_callable = _is_callable_fwhm(fwhm)
|
|
944
|
+
|
|
945
|
+
if fwhm_s is not None:
|
|
946
|
+
if fwhm_is_callable:
|
|
947
|
+
log('Scaling aperture radii with spatial FWHM (median %.1f pix)' % fwhm_s)
|
|
948
|
+
else:
|
|
949
|
+
log('Scaling aperture radii with FWHM %.1f pix' % fwhm_s)
|
|
950
|
+
aper = aper * fwhm_s
|
|
951
|
+
|
|
952
|
+
log('Using aperture radius %.1f pixels' % aper)
|
|
953
|
+
|
|
954
|
+
# Centroiding
|
|
955
|
+
if centroid_iter:
|
|
956
|
+
box_size = int(np.ceil(aper))
|
|
957
|
+
if box_size % 2 == 0:
|
|
958
|
+
box_size += 1
|
|
959
|
+
|
|
960
|
+
# Determine centroiding method
|
|
961
|
+
use_psf_centroid = centroid_method == 'psf' and (psf is not None or fwhm_s is not None)
|
|
962
|
+
|
|
963
|
+
if use_psf_centroid:
|
|
964
|
+
# Prepare PSF for centroiding
|
|
965
|
+
if psf is not None:
|
|
966
|
+
psf_for_centroid = psf
|
|
967
|
+
log(
|
|
968
|
+
'Using PSF-weighted centroiding with %d iterations within %dx%d box'
|
|
969
|
+
% (centroid_iter, box_size, box_size)
|
|
970
|
+
)
|
|
971
|
+
else:
|
|
972
|
+
# PSF-weighted centroiding uses a scalar FWHM; for a spatial
|
|
973
|
+
# model, the scalar summary (median) is a sensible proxy.
|
|
974
|
+
psf_for_centroid = fwhm_s
|
|
975
|
+
log(
|
|
976
|
+
'Using PSF-weighted centroiding (Gaussian FWHM=%.1f) with %d iterations within %dx%d box'
|
|
977
|
+
% (fwhm_s, centroid_iter, box_size, box_size)
|
|
978
|
+
)
|
|
979
|
+
else:
|
|
980
|
+
log(
|
|
981
|
+
'Using COM centroiding with %d iterations within %dx%d box'
|
|
982
|
+
% (centroid_iter, box_size, box_size)
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
# Keep original pixel positions
|
|
986
|
+
obj['x_orig'] = np.array(obj['x'])
|
|
987
|
+
obj['y_orig'] = np.array(obj['y'])
|
|
988
|
+
|
|
989
|
+
# Combined mask for centroiding
|
|
990
|
+
centroid_mask = mask | mask0
|
|
991
|
+
|
|
992
|
+
# Get plain arrays to avoid MaskedColumn warnings
|
|
993
|
+
xs = np.array(obj['x'], dtype=float)
|
|
994
|
+
ys = np.array(obj['y'], dtype=float)
|
|
995
|
+
|
|
996
|
+
# Process each object individually
|
|
997
|
+
for i in range(len(obj)):
|
|
998
|
+
x, y = xs[i], ys[i]
|
|
999
|
+
|
|
1000
|
+
# Skip invalid positions (NaN or outside image with margin for box)
|
|
1001
|
+
if not np.isfinite(x) or not np.isfinite(y):
|
|
1002
|
+
continue
|
|
1003
|
+
if x < box_size // 2 or x >= image1.shape[1] - box_size // 2:
|
|
1004
|
+
continue
|
|
1005
|
+
if y < box_size // 2 or y >= image1.shape[0] - box_size // 2:
|
|
1006
|
+
continue
|
|
1007
|
+
|
|
1008
|
+
# Iterative centroiding
|
|
1009
|
+
for _ in range(centroid_iter):
|
|
1010
|
+
if use_psf_centroid:
|
|
1011
|
+
# PSF-weighted centroiding
|
|
1012
|
+
x_new, y_new = _psf_centroid(
|
|
1013
|
+
image1, x, y, psf_for_centroid, mask=centroid_mask, box_size=box_size
|
|
1014
|
+
)
|
|
1015
|
+
else:
|
|
1016
|
+
# Standard COM centroiding
|
|
1017
|
+
# Extract cutout around current position
|
|
1018
|
+
x_int, y_int = int(round(x)), int(round(y))
|
|
1019
|
+
x_min = x_int - box_size // 2
|
|
1020
|
+
x_max = x_int + box_size // 2 + 1
|
|
1021
|
+
y_min = y_int - box_size // 2
|
|
1022
|
+
y_max = y_int + box_size // 2 + 1
|
|
1023
|
+
|
|
1024
|
+
# Check bounds (position may have shifted during iteration)
|
|
1025
|
+
if x_min < 0 or y_min < 0 or x_max > image1.shape[1] or y_max > image1.shape[0]:
|
|
1026
|
+
break
|
|
1027
|
+
|
|
1028
|
+
cutout = image1[y_min:y_max, x_min:x_max]
|
|
1029
|
+
# Let's only use positive pixels for centroiding
|
|
1030
|
+
cutout_mask = centroid_mask[y_min:y_max, x_min:x_max] | (cutout < 0)
|
|
1031
|
+
|
|
1032
|
+
# Skip if footprint is fully masked
|
|
1033
|
+
if np.all(cutout_mask):
|
|
1034
|
+
break
|
|
1035
|
+
|
|
1036
|
+
# Compute centroid in cutout coordinates
|
|
1037
|
+
x_c, y_c = photutils.centroids.centroid_com(cutout, mask=cutout_mask)
|
|
1038
|
+
|
|
1039
|
+
# Skip if centroid computation failed
|
|
1040
|
+
if not np.isfinite(x_c) or not np.isfinite(y_c):
|
|
1041
|
+
break
|
|
1042
|
+
|
|
1043
|
+
# Convert back to image coordinates
|
|
1044
|
+
x_new = x_min + x_c
|
|
1045
|
+
y_new = y_min + y_c
|
|
1046
|
+
|
|
1047
|
+
# Check convergence
|
|
1048
|
+
if np.isfinite(x_new) and np.isfinite(y_new):
|
|
1049
|
+
shift = np.sqrt((x_new - x) ** 2 + (y_new - y) ** 2)
|
|
1050
|
+
x, y = x_new, y_new
|
|
1051
|
+
if shift < 0.01: # Converged (0.01 pixel threshold)
|
|
1052
|
+
break
|
|
1053
|
+
else:
|
|
1054
|
+
# Centroid computation failed, keep previous position
|
|
1055
|
+
break
|
|
1056
|
+
|
|
1057
|
+
# Update object position
|
|
1058
|
+
obj['x'][i] = x
|
|
1059
|
+
obj['y'][i] = y
|
|
1060
|
+
|
|
1061
|
+
if 'flags' not in obj.keys():
|
|
1062
|
+
obj['flags'] = 0
|
|
1063
|
+
|
|
1064
|
+
# Dedicated column for local background on top of global estimation
|
|
1065
|
+
obj['bg_local'] = 0
|
|
1066
|
+
|
|
1067
|
+
# Local background - it has to be computed prior to optimal photometry
|
|
1068
|
+
# as otherwise it is too difficult to subtract it from its (weighted) results
|
|
1069
|
+
if bkgann is not None and len(bkgann) == 2:
|
|
1070
|
+
if fwhm_s is not None:
|
|
1071
|
+
# LocalBackground/GradientLocalBackground both take scalar radii,
|
|
1072
|
+
# so we use the median of the spatial FWHM here.
|
|
1073
|
+
bkgann = [_ * fwhm_s for _ in bkgann]
|
|
1074
|
+
|
|
1075
|
+
order_names = {0: 'constant (mean)', 1: 'plane', 2: 'quadratic'}
|
|
1076
|
+
order_name = order_names.get(bkg_order, f'order {bkg_order}')
|
|
1077
|
+
log(
|
|
1078
|
+
'Using local background annulus between %.1f and %.1f pixels with %s fitting'
|
|
1079
|
+
% (bkgann[0], bkgann[1], order_name)
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
x_vals, y_vals, valid_pos = _extract_valid_positions(obj)
|
|
1083
|
+
|
|
1084
|
+
# Initialize bg_local with zeros
|
|
1085
|
+
obj['bg_local'] = 0.0
|
|
1086
|
+
|
|
1087
|
+
# Only compute local background for valid positions
|
|
1088
|
+
if np.any(valid_pos):
|
|
1089
|
+
if bkg_order == 0:
|
|
1090
|
+
# Use photutils LocalBackground for order=0 (performance)
|
|
1091
|
+
lbg = photutils.background.LocalBackground(
|
|
1092
|
+
bkgann[0],
|
|
1093
|
+
bkgann[1],
|
|
1094
|
+
bkg_estimator=photutils.background.ModeEstimatorBackground(),
|
|
1095
|
+
)
|
|
1096
|
+
obj['bg_local'][valid_pos] = lbg(
|
|
1097
|
+
image1, x_vals[valid_pos], y_vals[valid_pos], mask=mask | mask0
|
|
1098
|
+
)
|
|
1099
|
+
else:
|
|
1100
|
+
# Use gradient-aware fitting for order > 0
|
|
1101
|
+
# GradientLocalBackground handles array inputs efficiently
|
|
1102
|
+
# Lazy import to avoid circular dependency
|
|
1103
|
+
from .photometry_psf import GradientLocalBackground
|
|
1104
|
+
|
|
1105
|
+
grad_bkg = GradientLocalBackground(bkgann[0], bkgann[1], order=bkg_order)
|
|
1106
|
+
obj['bg_local'][valid_pos] = grad_bkg(
|
|
1107
|
+
image1, x_vals[valid_pos], y_vals[valid_pos], mask=mask | mask0
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
# Flag invalid positions and positions where local bg estimation failed
|
|
1111
|
+
idx = ~valid_pos | ~np.isfinite(obj['bg_local'])
|
|
1112
|
+
obj['flags'][idx] |= 0x400
|
|
1113
|
+
obj['bg_local'][idx] = 0
|
|
1114
|
+
|
|
1115
|
+
# Photometric apertures
|
|
1116
|
+
x_vals, y_vals, valid_pos = _extract_valid_positions(obj)
|
|
1117
|
+
valid_idx = np.where(valid_pos)[0]
|
|
1118
|
+
|
|
1119
|
+
apertures = None
|
|
1120
|
+
obj['npix_aper'] = 0.0
|
|
1121
|
+
obj['bg_fluxerr'] = 0.0 # Local background flux error inside the aperture
|
|
1122
|
+
if np.any(valid_pos):
|
|
1123
|
+
positions = list(zip(x_vals[valid_pos], y_vals[valid_pos]))
|
|
1124
|
+
apertures = photutils.aperture.CircularAperture(positions, r=aper)
|
|
1125
|
+
|
|
1126
|
+
# Check whether some aperture pixels are masked, and set the flags for that
|
|
1127
|
+
mres = photutils.aperture.aperture_photometry(mask | mask0, apertures, method='center')
|
|
1128
|
+
obj['flags'][valid_idx[mres['aperture_sum'] > 0]] |= 0x200
|
|
1129
|
+
|
|
1130
|
+
# Aperture unmasked areas, in (fractional) pixels
|
|
1131
|
+
image_ones = np.ones(image1.shape)
|
|
1132
|
+
res_area = photutils.aperture.aperture_photometry(image_ones, apertures, mask=mask0)
|
|
1133
|
+
obj['npix_aper'][valid_idx] = res_area['aperture_sum']
|
|
1134
|
+
|
|
1135
|
+
# Position-dependent background flux error from global background model, if available
|
|
1136
|
+
if bg_est is not None:
|
|
1137
|
+
res = photutils.aperture.aperture_photometry(bg_est_rms**2, apertures)
|
|
1138
|
+
obj['bg_fluxerr'][valid_idx] = np.sqrt(res['aperture_sum'])
|
|
1139
|
+
|
|
1140
|
+
if optimal:
|
|
1141
|
+
# Optimal extraction photometry (Naylor 1998)
|
|
1142
|
+
log('Using optimal extraction photometry')
|
|
1143
|
+
|
|
1144
|
+
# Determine PSF model. A callable ``fwhm`` (e.g. FWHMMap) yields a
|
|
1145
|
+
# per-source Gaussian width in the ungrouped path below.
|
|
1146
|
+
if psf is not None:
|
|
1147
|
+
psf_for_extraction = psf
|
|
1148
|
+
log('Using provided PSF model for optimal extraction')
|
|
1149
|
+
elif fwhm_s is not None:
|
|
1150
|
+
psf_for_extraction = fwhm if fwhm_is_callable else fwhm_s
|
|
1151
|
+
if fwhm_is_callable:
|
|
1152
|
+
log('Using spatial Gaussian PSF for optimal extraction (median FWHM=%.1f)' % fwhm_s)
|
|
1153
|
+
else:
|
|
1154
|
+
log('Using Gaussian PSF with FWHM=%.1f for optimal extraction' % fwhm_s)
|
|
1155
|
+
else:
|
|
1156
|
+
raise ValueError("Either 'psf' or 'fwhm' must be provided for optimal extraction")
|
|
1157
|
+
|
|
1158
|
+
# Initialize output columns
|
|
1159
|
+
obj['flux'] = np.nan
|
|
1160
|
+
obj['fluxerr'] = np.nan
|
|
1161
|
+
obj['npix_optimal'] = 0
|
|
1162
|
+
obj['chi2_optimal'] = np.nan
|
|
1163
|
+
obj['norm_optimal'] = np.nan
|
|
1164
|
+
|
|
1165
|
+
if group_sources:
|
|
1166
|
+
# Grouped optimal extraction for crowded fields
|
|
1167
|
+
if grouper_radius is None:
|
|
1168
|
+
grouper_radius = 2 * aper
|
|
1169
|
+
log('Using grouped optimal extraction with grouper radius %.1f pixels' % grouper_radius)
|
|
1170
|
+
|
|
1171
|
+
# Add group columns
|
|
1172
|
+
obj['group_id'] = -1
|
|
1173
|
+
obj['group_size'] = 0
|
|
1174
|
+
|
|
1175
|
+
# Use SourceGrouper to identify groups
|
|
1176
|
+
grouper = photutils.psf.SourceGrouper(min_separation=grouper_radius)
|
|
1177
|
+
|
|
1178
|
+
x_vals, y_vals, valid_pos = _extract_valid_positions(obj)
|
|
1179
|
+
|
|
1180
|
+
# Get group IDs only for valid positions, then map back
|
|
1181
|
+
if np.any(valid_pos):
|
|
1182
|
+
group_ids_valid = grouper(x_vals[valid_pos], y_vals[valid_pos])
|
|
1183
|
+
# Map group IDs back to full array (-1 for invalid positions)
|
|
1184
|
+
group_ids = np.full(len(obj), -1, dtype=int)
|
|
1185
|
+
group_ids[valid_pos] = group_ids_valid
|
|
1186
|
+
else:
|
|
1187
|
+
group_ids = np.full(len(obj), -1, dtype=int)
|
|
1188
|
+
obj['group_id'] = group_ids
|
|
1189
|
+
|
|
1190
|
+
# Process each group (skip -1 which are invalid/masked positions)
|
|
1191
|
+
unique_groups = np.unique(group_ids)
|
|
1192
|
+
for gid in unique_groups:
|
|
1193
|
+
if gid < 0:
|
|
1194
|
+
continue # Skip invalid positions group
|
|
1195
|
+
|
|
1196
|
+
group_mask = group_ids == gid
|
|
1197
|
+
group_indices = np.where(group_mask)[0]
|
|
1198
|
+
group_size = len(group_indices)
|
|
1199
|
+
|
|
1200
|
+
# Update group_size for all members
|
|
1201
|
+
obj['group_size'][group_mask] = group_size
|
|
1202
|
+
|
|
1203
|
+
# Get positions and backgrounds for this group
|
|
1204
|
+
positions = [
|
|
1205
|
+
(obj['x'][i], obj['y'][i])
|
|
1206
|
+
for i in group_indices
|
|
1207
|
+
if np.isfinite(obj['x'][i]) and np.isfinite(obj['y'][i])
|
|
1208
|
+
]
|
|
1209
|
+
bg_locals = [
|
|
1210
|
+
obj['bg_local'][i]
|
|
1211
|
+
for i in group_indices
|
|
1212
|
+
if np.isfinite(obj['x'][i]) and np.isfinite(obj['y'][i])
|
|
1213
|
+
]
|
|
1214
|
+
|
|
1215
|
+
if len(positions) == 0:
|
|
1216
|
+
continue
|
|
1217
|
+
|
|
1218
|
+
# Perform grouped extraction. The grouped helper shares a
|
|
1219
|
+
# single PSF across the group, so for a spatial FWHM model
|
|
1220
|
+
# we evaluate it at the group mean and pass the scalar.
|
|
1221
|
+
if fwhm_is_callable and psf is None:
|
|
1222
|
+
xs_g = np.array([p[0] for p in positions])
|
|
1223
|
+
ys_g = np.array([p[1] for p in positions])
|
|
1224
|
+
grp_fwhm = float(np.median(_fwhm_at(fwhm, xs_g, ys_g)))
|
|
1225
|
+
psf_for_group = grp_fwhm
|
|
1226
|
+
else:
|
|
1227
|
+
psf_for_group = psf_for_extraction
|
|
1228
|
+
results = _grouped_optimal_extraction(
|
|
1229
|
+
image1,
|
|
1230
|
+
err,
|
|
1231
|
+
positions,
|
|
1232
|
+
psf_for_group,
|
|
1233
|
+
bg_local=bg_locals,
|
|
1234
|
+
mask=mask | mask0,
|
|
1235
|
+
radius=aper,
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
# Store results
|
|
1239
|
+
valid_idx = 0
|
|
1240
|
+
for i in group_indices:
|
|
1241
|
+
if np.isfinite(obj['x'][i]) and np.isfinite(obj['y'][i]):
|
|
1242
|
+
res = results[valid_idx]
|
|
1243
|
+
obj['flux'][i] = res[0]
|
|
1244
|
+
obj['fluxerr'][i] = res[1]
|
|
1245
|
+
obj['npix_optimal'][i] = res[2]
|
|
1246
|
+
obj['norm_optimal'][i] = res[3]
|
|
1247
|
+
obj['chi2_optimal'][i] = res[4]
|
|
1248
|
+
valid_idx += 1
|
|
1249
|
+
|
|
1250
|
+
log(
|
|
1251
|
+
'Processed %d groups (%d isolated, %d grouped)'
|
|
1252
|
+
% (
|
|
1253
|
+
len(unique_groups),
|
|
1254
|
+
np.sum(obj['group_size'] == 1),
|
|
1255
|
+
np.sum(obj['group_size'] > 1),
|
|
1256
|
+
)
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
else:
|
|
1260
|
+
# Standard single-source optimal extraction
|
|
1261
|
+
if fwhm_is_callable and psf is None:
|
|
1262
|
+
x_vals, y_vals, _ = _extract_valid_positions(obj)
|
|
1263
|
+
fwhm_per_source = np.asarray(fwhm(x_vals, y_vals), dtype=float)
|
|
1264
|
+
if fwhm_per_source.ndim == 0:
|
|
1265
|
+
fwhm_per_source = np.full(len(obj), float(fwhm_per_source))
|
|
1266
|
+
else:
|
|
1267
|
+
fwhm_per_source = None
|
|
1268
|
+
|
|
1269
|
+
for i, o in enumerate(obj):
|
|
1270
|
+
if np.isfinite(o['x']) and np.isfinite(o['y']):
|
|
1271
|
+
if fwhm_per_source is not None:
|
|
1272
|
+
psf_here = float(fwhm_per_source[i])
|
|
1273
|
+
else:
|
|
1274
|
+
psf_here = psf_for_extraction
|
|
1275
|
+
res = _optimal_extraction(
|
|
1276
|
+
image1,
|
|
1277
|
+
err,
|
|
1278
|
+
o['x'],
|
|
1279
|
+
o['y'],
|
|
1280
|
+
psf_here,
|
|
1281
|
+
bg_local=o['bg_local'],
|
|
1282
|
+
mask=mask | mask0, # Do not count the flux from 'soft-masked' pixels
|
|
1283
|
+
radius=aper, # Use aperture radius as clipping radius
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
(
|
|
1287
|
+
o['flux'],
|
|
1288
|
+
o['fluxerr'],
|
|
1289
|
+
o['npix_optimal'],
|
|
1290
|
+
o['norm_optimal'],
|
|
1291
|
+
o['chi2_optimal'],
|
|
1292
|
+
) = res
|
|
1293
|
+
|
|
1294
|
+
# Flag objects where optimal extraction failed
|
|
1295
|
+
obj['flags'][~np.isfinite(obj['flux'])] |= 0x800
|
|
1296
|
+
|
|
1297
|
+
# Flag objects with poor fits (chi2 > 1000 indicates numerical instability or crowding)
|
|
1298
|
+
# These typically occur in very crowded groups where the matrix becomes ill-conditioned
|
|
1299
|
+
bad_chi2 = np.isfinite(obj['chi2_optimal']) & (obj['chi2_optimal'] > 1000)
|
|
1300
|
+
obj['flags'][bad_chi2] |= 0x1000
|
|
1301
|
+
|
|
1302
|
+
else:
|
|
1303
|
+
# Standard aperture photometry
|
|
1304
|
+
# Use just a minimal mask here so that the flux from 'soft-masked' (e.g. saturated) pixels is still counted
|
|
1305
|
+
obj['flux'] = np.nan
|
|
1306
|
+
obj['fluxerr'] = np.nan
|
|
1307
|
+
if apertures is not None:
|
|
1308
|
+
res = photutils.aperture.aperture_photometry(image1, apertures, error=err, mask=mask0)
|
|
1309
|
+
obj['flux'][valid_idx] = res['aperture_sum']
|
|
1310
|
+
obj['fluxerr'][valid_idx] = res['aperture_sum_err']
|
|
1311
|
+
|
|
1312
|
+
# Subtract local background
|
|
1313
|
+
obj['flux'][valid_pos] -= obj['bg_local'][valid_pos] * obj['npix_aper'][valid_pos]
|
|
1314
|
+
|
|
1315
|
+
obj = _compute_magnitudes_and_filter(obj, sn, keep_negative, log)
|
|
1316
|
+
|
|
1317
|
+
if get_bg:
|
|
1318
|
+
return obj, bg_est_bg, err
|
|
1319
|
+
else:
|
|
1320
|
+
return obj
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _get_sep_psf(psf, fwhm, log):
|
|
1324
|
+
"""Convert a PSF model to sep.PSF object for use with sep.psf_fit().
|
|
1325
|
+
|
|
1326
|
+
Accepts sep.PSF objects (returned as-is) or PSFEx dicts from
|
|
1327
|
+
``stdpipe.psf.run_psfex()`` / ``load_psf()``.
|
|
1328
|
+
|
|
1329
|
+
Parameters
|
|
1330
|
+
----------
|
|
1331
|
+
psf : sep.PSF or dict
|
|
1332
|
+
PSF model (sep.PSF or PSFEx dict).
|
|
1333
|
+
fwhm : float
|
|
1334
|
+
FWHM in pixels (unused, reserved for future use).
|
|
1335
|
+
log : callable
|
|
1336
|
+
Logging function.
|
|
1337
|
+
|
|
1338
|
+
Returns
|
|
1339
|
+
-------
|
|
1340
|
+
sep_psf : sep.PSF
|
|
1341
|
+
SEP PSF object.
|
|
1342
|
+
"""
|
|
1343
|
+
if isinstance(psf, sep.PSF):
|
|
1344
|
+
log('Using provided sep.PSF model (FWHM=%.2f)' % psf.fwhm)
|
|
1345
|
+
return psf
|
|
1346
|
+
|
|
1347
|
+
if isinstance(psf, dict) and 'data' in psf and 'sampling' in psf:
|
|
1348
|
+
# PSFEx-like dict structure from run_psfex / load_psf
|
|
1349
|
+
log(
|
|
1350
|
+
'Converting PSFEx model to sep.PSF (FWHM=%.2f, sampling=%.3f, degree=%d)'
|
|
1351
|
+
% (psf['fwhm'], psf['sampling'], psf.get('degree', 0))
|
|
1352
|
+
)
|
|
1353
|
+
sep_psf = sep.PSF(
|
|
1354
|
+
psf['data'],
|
|
1355
|
+
sampling=psf['sampling'],
|
|
1356
|
+
degree=psf.get('degree', 0),
|
|
1357
|
+
x0=psf.get('x0', 0),
|
|
1358
|
+
y0=psf.get('y0', 0),
|
|
1359
|
+
sx=psf.get('sx', 1),
|
|
1360
|
+
sy=psf.get('sy', 1),
|
|
1361
|
+
fwhm=psf['fwhm'],
|
|
1362
|
+
)
|
|
1363
|
+
return sep_psf
|
|
1364
|
+
|
|
1365
|
+
raise TypeError("Unsupported PSF type: %s. Expected sep.PSF or PSFEx dict." % type(psf))
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def measure_objects_sep(
|
|
1369
|
+
obj,
|
|
1370
|
+
image,
|
|
1371
|
+
aper=3,
|
|
1372
|
+
bkgann=None,
|
|
1373
|
+
fwhm=None,
|
|
1374
|
+
psf=None,
|
|
1375
|
+
optimal=False,
|
|
1376
|
+
group_sources=True,
|
|
1377
|
+
group_factor=2.0,
|
|
1378
|
+
group_radius_factor=1.0,
|
|
1379
|
+
group_halo_factor=1.2,
|
|
1380
|
+
maxiter=20,
|
|
1381
|
+
fit_positions=True,
|
|
1382
|
+
fit_radius=0.0,
|
|
1383
|
+
damp_snthresh=0.0,
|
|
1384
|
+
mask=None,
|
|
1385
|
+
bg=None,
|
|
1386
|
+
err=None,
|
|
1387
|
+
gain=None,
|
|
1388
|
+
bg_size=64,
|
|
1389
|
+
sn=None,
|
|
1390
|
+
centroid_iter=0,
|
|
1391
|
+
centroid_psf=None,
|
|
1392
|
+
keep_negative=True,
|
|
1393
|
+
get_bg=False,
|
|
1394
|
+
clip_sigma=3.0,
|
|
1395
|
+
clip_iters=5,
|
|
1396
|
+
verbose=False,
|
|
1397
|
+
):
|
|
1398
|
+
"""Photometry at the positions of already detected objects using SEP routines.
|
|
1399
|
+
|
|
1400
|
+
Uses SEP's built-in features for optimal extraction with sigma-clipped
|
|
1401
|
+
background (via ``sum_circle_optimal``), PSF fitting photometry (via
|
|
1402
|
+
``sep.psf_fit``), and iterative centroiding (via ``winpos``).
|
|
1403
|
+
|
|
1404
|
+
Only available if SEP version 1.4+ with these features is installed.
|
|
1405
|
+
|
|
1406
|
+
Parameters
|
|
1407
|
+
----------
|
|
1408
|
+
obj : astropy.table.Table
|
|
1409
|
+
Table with initial object detections to be measured.
|
|
1410
|
+
image : ndarray
|
|
1411
|
+
Input image as a NumPy array.
|
|
1412
|
+
aper : float
|
|
1413
|
+
Circular aperture radius in pixels for flux measurement.
|
|
1414
|
+
bkgann : tuple of float or None
|
|
1415
|
+
Background annulus (inner, outer radii) for local background.
|
|
1416
|
+
For optimal extraction, SEP handles this internally with
|
|
1417
|
+
sigma-clipping. For aperture photometry, ``sep.stats_circann()``
|
|
1418
|
+
is used.
|
|
1419
|
+
fwhm : float, callable, or None
|
|
1420
|
+
If provided, ``aper`` and ``bkgann`` are measured in units of FWHM.
|
|
1421
|
+
Also used for Gaussian PSF in optimal extraction, PSF fitting,
|
|
1422
|
+
and centroiding. A callable (e.g. a
|
|
1423
|
+
:class:`stdpipe.photometry.FWHMMap`) is accepted and evaluated at
|
|
1424
|
+
each source position: per-source aperture/bkgann arrays feed the
|
|
1425
|
+
SEP photometry routines and the per-source width feeds
|
|
1426
|
+
``sep.sum_circle_optimal``. The scalar summary ``float(fwhm)``
|
|
1427
|
+
is used for the SEP windowed centroider (which requires a scalar
|
|
1428
|
+
sigma).
|
|
1429
|
+
psf : dict, sep.PSF, or None
|
|
1430
|
+
PSF model for PSF fitting photometry. When provided, PSF fitting is
|
|
1431
|
+
used instead of aperture or optimal extraction. Can be a PSFEx dict
|
|
1432
|
+
from ``stdpipe.psf.run_psfex()`` or a ``sep.PSF`` object.
|
|
1433
|
+
optimal : bool
|
|
1434
|
+
If True, use optimal extraction via ``sep.sum_circle_optimal()``.
|
|
1435
|
+
Requires ``fwhm``. Ignored when ``psf`` is provided.
|
|
1436
|
+
group_sources : bool
|
|
1437
|
+
If True, use grouped fitting for optimal or PSF photometry.
|
|
1438
|
+
group_factor : float
|
|
1439
|
+
Local fitting halo factor for grouped PSF fitting. Only used for
|
|
1440
|
+
PSF fitting when ``group_sources=True``; SEP determines PSF-fit
|
|
1441
|
+
connectivity from direct fit-support overlap.
|
|
1442
|
+
group_radius_factor : float
|
|
1443
|
+
Connectivity scale for grouped optimal extraction. Only used for
|
|
1444
|
+
optimal extraction when ``group_sources=True``. The default 1.0
|
|
1445
|
+
groups sources whose apertures overlap.
|
|
1446
|
+
group_halo_factor : float or None
|
|
1447
|
+
Local context halo scale for grouped optimal extraction. The default
|
|
1448
|
+
1.2 keeps a modest expanded solve context without widening source
|
|
1449
|
+
connectivity. ``None`` lets SEP use ``group_radius_factor``.
|
|
1450
|
+
maxiter : int
|
|
1451
|
+
Maximum number of PSF fitting iterations.
|
|
1452
|
+
fit_positions : bool
|
|
1453
|
+
If True, fit source positions during PSF fitting.
|
|
1454
|
+
fit_radius : float
|
|
1455
|
+
If > 0, only pixels within this radius (in pixels) of the source
|
|
1456
|
+
center participate in PSF fitting. 0 means use the full PSF stamp.
|
|
1457
|
+
Values of 2-3x FWHM reduce scatter in crowded fields.
|
|
1458
|
+
damp_snthresh : float
|
|
1459
|
+
S/N threshold for damping PSF fit updates.
|
|
1460
|
+
mask : ndarray of bool or None
|
|
1461
|
+
Image mask (True values are masked).
|
|
1462
|
+
bg : ndarray or None
|
|
1463
|
+
If provided, use this background instead of automatically computed
|
|
1464
|
+
one.
|
|
1465
|
+
err : ndarray or None
|
|
1466
|
+
Image noise map.
|
|
1467
|
+
gain : float or None
|
|
1468
|
+
Image gain in e-/ADU, used to build image noise model.
|
|
1469
|
+
bg_size : int
|
|
1470
|
+
Background grid size in pixels.
|
|
1471
|
+
sn : float or None
|
|
1472
|
+
Minimal S/N ratio for filtering.
|
|
1473
|
+
centroid_iter : int
|
|
1474
|
+
Number of centroiding iterations (uses SEP's built-in iteration).
|
|
1475
|
+
centroid_psf : dict, sep.PSF, or None
|
|
1476
|
+
PSF model for centroiding. If None, uses ``psf`` if available,
|
|
1477
|
+
otherwise Gaussian windowed centroiding.
|
|
1478
|
+
keep_negative : bool
|
|
1479
|
+
If False, measurements with negative fluxes will be discarded.
|
|
1480
|
+
get_bg : bool
|
|
1481
|
+
If True, also return estimated background and noise images.
|
|
1482
|
+
clip_sigma : float
|
|
1483
|
+
Sigma value for clipping when ``bkgann`` is provided.
|
|
1484
|
+
clip_iters : int
|
|
1485
|
+
Maximum number of clipping iterations when ``bkgann`` is provided.
|
|
1486
|
+
Set to 0 to disable clipping.
|
|
1487
|
+
verbose : bool or callable
|
|
1488
|
+
Whether to show verbose messages. May be boolean or print-like
|
|
1489
|
+
function.
|
|
1490
|
+
|
|
1491
|
+
Returns
|
|
1492
|
+
-------
|
|
1493
|
+
obj : astropy.table.Table
|
|
1494
|
+
Copy of table with ``flux``, ``fluxerr``, ``mag`` and ``magerr``
|
|
1495
|
+
columns from SEP measurements. When ``psf`` is provided, also
|
|
1496
|
+
includes ``x_psf``, ``y_psf`` (fitted positions), ``chi2_psf``,
|
|
1497
|
+
``niter_psf``, and ``flags_psf`` columns.
|
|
1498
|
+
bg_image : ndarray
|
|
1499
|
+
Background image (only returned if ``get_bg=True``).
|
|
1500
|
+
err_image : ndarray
|
|
1501
|
+
Error image (only returned if ``get_bg=True``).
|
|
1502
|
+
|
|
1503
|
+
Notes
|
|
1504
|
+
-----
|
|
1505
|
+
Quality flags set in the ``flags`` column:
|
|
1506
|
+
|
|
1507
|
+
- 0x200: At least one aperture pixel is masked (aperture/optimal paths)
|
|
1508
|
+
- 0x400: Invalid position
|
|
1509
|
+
- 0x800: Optimal extraction failed (NaN result)
|
|
1510
|
+
- 0x1000: PSF fit returned non-zero quality flag, or PSF fit failed (NaN result)
|
|
1511
|
+
- 0x2000: Large centroid shift during PSF fit (>1 pixel)
|
|
1512
|
+
"""
|
|
1513
|
+
if not _HAS_SEP_OPTIMAL:
|
|
1514
|
+
raise RuntimeError(
|
|
1515
|
+
"measure_objects_sep() requires SEP-X (the sep-x package) providing "
|
|
1516
|
+
"sum_circle_optimal() and stats_circann(). Please install sep-x or use "
|
|
1517
|
+
"measure_objects() instead."
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1521
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1522
|
+
|
|
1523
|
+
if not len(obj):
|
|
1524
|
+
log('No objects to measure')
|
|
1525
|
+
return obj
|
|
1526
|
+
|
|
1527
|
+
# Operate on the copy of the list
|
|
1528
|
+
obj = obj.copy()
|
|
1529
|
+
|
|
1530
|
+
image1, mask0, mask = _prepare_image_and_mask(image, mask)
|
|
1531
|
+
|
|
1532
|
+
# SEP requires C-contiguous arrays
|
|
1533
|
+
image1 = np.ascontiguousarray(image1)
|
|
1534
|
+
|
|
1535
|
+
if bg is None or err is None or get_bg:
|
|
1536
|
+
log('Estimating global background with SEP')
|
|
1537
|
+
bg_est = sep.Background(image1, mask=mask | mask0, bw=bg_size, bh=bg_size)
|
|
1538
|
+
bg_est_bg = bg_est.back()
|
|
1539
|
+
bg_est_rms = bg_est.rms()
|
|
1540
|
+
else:
|
|
1541
|
+
bg_est = None
|
|
1542
|
+
|
|
1543
|
+
if bg is None:
|
|
1544
|
+
log(
|
|
1545
|
+
'Subtracting global background: median %.1f rms %.2f'
|
|
1546
|
+
% (np.median(bg_est_bg), np.std(bg_est_bg))
|
|
1547
|
+
)
|
|
1548
|
+
image1 -= bg_est_bg
|
|
1549
|
+
else:
|
|
1550
|
+
log(
|
|
1551
|
+
'Subtracting user-provided background: median %.1f rms %.2f'
|
|
1552
|
+
% (np.median(bg), np.std(bg))
|
|
1553
|
+
)
|
|
1554
|
+
image1 -= bg
|
|
1555
|
+
|
|
1556
|
+
image1[mask0] = 0
|
|
1557
|
+
|
|
1558
|
+
if err is None:
|
|
1559
|
+
log(
|
|
1560
|
+
'Using global background noise map: median %.1f rms %.2f'
|
|
1561
|
+
% (
|
|
1562
|
+
np.median(bg_est_rms),
|
|
1563
|
+
np.std(bg_est_rms),
|
|
1564
|
+
)
|
|
1565
|
+
)
|
|
1566
|
+
err = bg_est_rms
|
|
1567
|
+
else:
|
|
1568
|
+
log('Using user-provided noise map: median %.1f rms %.2f' % (np.median(err), np.std(err)))
|
|
1569
|
+
|
|
1570
|
+
# Ensure error map is C-contiguous
|
|
1571
|
+
err = np.ascontiguousarray(err)
|
|
1572
|
+
|
|
1573
|
+
fwhm_s = _fwhm_median(fwhm, image.shape)
|
|
1574
|
+
fwhm_is_callable = _is_callable_fwhm(fwhm)
|
|
1575
|
+
|
|
1576
|
+
if fwhm_s is not None:
|
|
1577
|
+
if fwhm_is_callable:
|
|
1578
|
+
log('Scaling aperture radii with spatial FWHM (median %.1f pix)' % fwhm_s)
|
|
1579
|
+
else:
|
|
1580
|
+
log('Scaling aperture radii with FWHM %.1f pix' % fwhm_s)
|
|
1581
|
+
# Scalar summary is used only for log output and as a fallback; the
|
|
1582
|
+
# actual per-source aperture radii are built below from fwhm_at_pos.
|
|
1583
|
+
aper_pix = aper * fwhm_s
|
|
1584
|
+
else:
|
|
1585
|
+
aper_pix = aper
|
|
1586
|
+
|
|
1587
|
+
log('Using aperture radius %.1f pixels' % aper_pix)
|
|
1588
|
+
|
|
1589
|
+
# Centroiding with SEP windowed positions (built-in iteration)
|
|
1590
|
+
if centroid_iter:
|
|
1591
|
+
# sep.winpos takes scalar sig/maxstep — use the scalar summary.
|
|
1592
|
+
maxstep = 0.2 * fwhm_s if fwhm_s else 0.6
|
|
1593
|
+
|
|
1594
|
+
# Build sep.PSF for PSF-weighted centroiding if requested
|
|
1595
|
+
sep_centroid_psf = None
|
|
1596
|
+
if centroid_psf is not None:
|
|
1597
|
+
sep_centroid_psf = _get_sep_psf(centroid_psf, fwhm, log)
|
|
1598
|
+
log('Using SEP PSF-weighted centroiding (maxstep=%.2f pix)' % maxstep)
|
|
1599
|
+
elif psf is not None:
|
|
1600
|
+
# Use the PSF model already provided for fitting
|
|
1601
|
+
sep_centroid_psf = _get_sep_psf(psf, fwhm, log)
|
|
1602
|
+
log('Using SEP PSF-weighted centroiding from psf model (maxstep=%.2f pix)' % maxstep)
|
|
1603
|
+
else:
|
|
1604
|
+
log('Using SEP Gaussian windowed centroiding (maxstep=%.2f pix)' % maxstep)
|
|
1605
|
+
|
|
1606
|
+
# Keep original pixel positions
|
|
1607
|
+
obj['x_orig'] = np.array(obj['x'])
|
|
1608
|
+
obj['y_orig'] = np.array(obj['y'])
|
|
1609
|
+
|
|
1610
|
+
x_vals, y_vals, valid_pos = _extract_valid_positions(obj)
|
|
1611
|
+
|
|
1612
|
+
if np.any(valid_pos):
|
|
1613
|
+
winpos_kwargs = dict(
|
|
1614
|
+
mask=mask | mask0,
|
|
1615
|
+
maxstep=maxstep,
|
|
1616
|
+
)
|
|
1617
|
+
if sep_centroid_psf is not None:
|
|
1618
|
+
winpos_kwargs['psf'] = sep_centroid_psf
|
|
1619
|
+
else:
|
|
1620
|
+
sigma = fwhm_s / 2.355 if fwhm_s else 1.0
|
|
1621
|
+
winpos_kwargs['sig'] = sigma
|
|
1622
|
+
|
|
1623
|
+
xwin, ywin, flag = sep.winpos(
|
|
1624
|
+
image1,
|
|
1625
|
+
x_vals[valid_pos],
|
|
1626
|
+
y_vals[valid_pos],
|
|
1627
|
+
**winpos_kwargs,
|
|
1628
|
+
)
|
|
1629
|
+
|
|
1630
|
+
# Update positions where centroiding succeeded and shift is reasonable
|
|
1631
|
+
total_shift = np.sqrt((xwin - x_vals[valid_pos]) ** 2 + (ywin - y_vals[valid_pos]) ** 2)
|
|
1632
|
+
max_total_shift = fwhm_s if fwhm_s else 3.0 # Reject shifts larger than 1 FWHM
|
|
1633
|
+
good_centroid = (
|
|
1634
|
+
(flag == 0)
|
|
1635
|
+
& np.isfinite(xwin)
|
|
1636
|
+
& np.isfinite(ywin)
|
|
1637
|
+
& (total_shift < max_total_shift)
|
|
1638
|
+
)
|
|
1639
|
+
update_indices = np.where(valid_pos)[0][good_centroid]
|
|
1640
|
+
|
|
1641
|
+
obj['x'][update_indices] = xwin[good_centroid]
|
|
1642
|
+
obj['y'][update_indices] = ywin[good_centroid]
|
|
1643
|
+
|
|
1644
|
+
if verbose:
|
|
1645
|
+
n_failed = np.sum(~good_centroid)
|
|
1646
|
+
n_converged = np.sum(good_centroid)
|
|
1647
|
+
median_shift = np.median(total_shift[good_centroid]) if np.any(good_centroid) else 0
|
|
1648
|
+
log(
|
|
1649
|
+
f' Centroiding: {n_converged} converged, {n_failed} failed/rejected, '
|
|
1650
|
+
f'median shift={median_shift:.3f} pix'
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
if 'flags' not in obj.keys():
|
|
1654
|
+
obj['flags'] = 0
|
|
1655
|
+
|
|
1656
|
+
x_vals, y_vals, valid_pos = _extract_valid_positions(obj)
|
|
1657
|
+
|
|
1658
|
+
# Flag invalid positions
|
|
1659
|
+
obj['flags'][~valid_pos] |= 0x400
|
|
1660
|
+
|
|
1661
|
+
if psf is not None and optimal:
|
|
1662
|
+
raise ValueError("'psf' and 'optimal' are mutually exclusive")
|
|
1663
|
+
|
|
1664
|
+
obj['flux'] = np.nan
|
|
1665
|
+
obj['fluxerr'] = np.nan
|
|
1666
|
+
|
|
1667
|
+
if np.any(valid_pos):
|
|
1668
|
+
# Evaluate FWHM at the valid source positions. For a callable
|
|
1669
|
+
# ``fwhm``, this becomes a per-source array fed directly to SEP's
|
|
1670
|
+
# broadcasted APIs; scalar ``fwhm`` remains a single float.
|
|
1671
|
+
fwhm_at_pos = None
|
|
1672
|
+
if fwhm_is_callable:
|
|
1673
|
+
fwhm_at_pos = np.asarray(fwhm(x_vals[valid_pos], y_vals[valid_pos]), dtype=float)
|
|
1674
|
+
if fwhm_at_pos.ndim == 0:
|
|
1675
|
+
fwhm_at_pos = np.full(int(np.sum(valid_pos)), float(fwhm_at_pos))
|
|
1676
|
+
|
|
1677
|
+
# Prepare per-source aperture radius.
|
|
1678
|
+
if fwhm_at_pos is not None:
|
|
1679
|
+
aper_arr = aper * fwhm_at_pos
|
|
1680
|
+
else:
|
|
1681
|
+
aper_arr = aper_pix
|
|
1682
|
+
|
|
1683
|
+
# Prepare bkgann in pixels.
|
|
1684
|
+
bkgann_pix = None
|
|
1685
|
+
if bkgann is not None and len(bkgann) == 2:
|
|
1686
|
+
if fwhm_at_pos is not None:
|
|
1687
|
+
bkgann_pix = (
|
|
1688
|
+
bkgann[0] * fwhm_at_pos,
|
|
1689
|
+
bkgann[1] * fwhm_at_pos,
|
|
1690
|
+
)
|
|
1691
|
+
elif fwhm_s is not None:
|
|
1692
|
+
bkgann_pix = (bkgann[0] * fwhm_s, bkgann[1] * fwhm_s)
|
|
1693
|
+
else:
|
|
1694
|
+
bkgann_pix = bkgann
|
|
1695
|
+
|
|
1696
|
+
if psf is not None:
|
|
1697
|
+
# PSF fitting photometry using sep.psf_fit()
|
|
1698
|
+
sep_psf = _get_sep_psf(psf, fwhm, log)
|
|
1699
|
+
|
|
1700
|
+
log(
|
|
1701
|
+
'Using SEP PSF fitting '
|
|
1702
|
+
'(grouped=%s, group_factor=%.2g, fit_positions=%s, '
|
|
1703
|
+
'maxiter=%d, fit_radius=%.1f, damp_snthresh=%.2f)'
|
|
1704
|
+
% (group_sources, group_factor, fit_positions, maxiter, fit_radius, damp_snthresh)
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
# Build keyword arguments for sep.psf_fit
|
|
1708
|
+
psf_kwargs = dict(
|
|
1709
|
+
err=err,
|
|
1710
|
+
gain=gain,
|
|
1711
|
+
mask=(mask | mask0).astype(np.uint8),
|
|
1712
|
+
grouped=group_sources,
|
|
1713
|
+
group_factor=group_factor,
|
|
1714
|
+
maxiter=maxiter,
|
|
1715
|
+
fit_positions=fit_positions,
|
|
1716
|
+
fit_radius=fit_radius,
|
|
1717
|
+
damp_snthresh=damp_snthresh,
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
flux, fluxerr, xfit, yfit, flag, chi2, niter = sep.psf_fit(
|
|
1721
|
+
image1,
|
|
1722
|
+
x_vals[valid_pos],
|
|
1723
|
+
y_vals[valid_pos],
|
|
1724
|
+
sep_psf,
|
|
1725
|
+
**psf_kwargs,
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
obj['flux'][valid_pos] = flux
|
|
1729
|
+
obj['fluxerr'][valid_pos] = fluxerr
|
|
1730
|
+
|
|
1731
|
+
# Store fitted positions
|
|
1732
|
+
obj['x_psf'] = np.nan
|
|
1733
|
+
obj['y_psf'] = np.nan
|
|
1734
|
+
obj['x_psf'][valid_pos] = xfit
|
|
1735
|
+
obj['y_psf'][valid_pos] = yfit
|
|
1736
|
+
|
|
1737
|
+
# Store PSF fit quality metrics
|
|
1738
|
+
obj['chi2_psf'] = np.nan
|
|
1739
|
+
obj['chi2_psf'][valid_pos] = chi2
|
|
1740
|
+
obj['niter_psf'] = 0
|
|
1741
|
+
obj['niter_psf'][valid_pos] = niter
|
|
1742
|
+
|
|
1743
|
+
# Store PSF fit flags
|
|
1744
|
+
obj['flags_psf'] = 0
|
|
1745
|
+
obj['flags_psf'][valid_pos] = flag
|
|
1746
|
+
|
|
1747
|
+
# Flag sources where PSF fit returned non-zero flag
|
|
1748
|
+
obj['flags'][np.where(valid_pos)[0][flag > 0]] |= 0x1000
|
|
1749
|
+
|
|
1750
|
+
# Flag large centroid shifts (>1 pixel)
|
|
1751
|
+
if fit_positions:
|
|
1752
|
+
dx_fit = xfit - x_vals[valid_pos]
|
|
1753
|
+
dy_fit = yfit - y_vals[valid_pos]
|
|
1754
|
+
large_shift = (dx_fit**2 + dy_fit**2) > 1.0
|
|
1755
|
+
obj['flags'][np.where(valid_pos)[0][large_shift]] |= 0x2000
|
|
1756
|
+
|
|
1757
|
+
elif optimal:
|
|
1758
|
+
# Optimal extraction using SEP with built-in background handling
|
|
1759
|
+
if fwhm_s is None:
|
|
1760
|
+
raise ValueError("'fwhm' must be provided for optimal extraction")
|
|
1761
|
+
|
|
1762
|
+
if bkgann_pix is not None:
|
|
1763
|
+
if fwhm_at_pos is not None:
|
|
1764
|
+
log(
|
|
1765
|
+
'Using SEP optimal extraction with sigma-clipped background annulus '
|
|
1766
|
+
'(per-source, median %.1f-%.1f)'
|
|
1767
|
+
% (float(np.median(bkgann_pix[0])), float(np.median(bkgann_pix[1])))
|
|
1768
|
+
)
|
|
1769
|
+
else:
|
|
1770
|
+
log(
|
|
1771
|
+
'Using SEP optimal extraction with sigma-clipped background annulus (%.1f, %.1f)'
|
|
1772
|
+
% tuple(bkgann_pix)
|
|
1773
|
+
)
|
|
1774
|
+
else:
|
|
1775
|
+
log('Using SEP optimal extraction (no local background)')
|
|
1776
|
+
|
|
1777
|
+
if group_sources:
|
|
1778
|
+
log(
|
|
1779
|
+
'Grouped optimal extraction enabled '
|
|
1780
|
+
'(radius_factor=%.2g, halo_factor=%s)'
|
|
1781
|
+
% (
|
|
1782
|
+
group_radius_factor,
|
|
1783
|
+
(
|
|
1784
|
+
'SEP default'
|
|
1785
|
+
if group_halo_factor is None
|
|
1786
|
+
else '%.2g' % group_halo_factor
|
|
1787
|
+
),
|
|
1788
|
+
)
|
|
1789
|
+
)
|
|
1790
|
+
|
|
1791
|
+
# SEP accepts scalar or per-source arrays for aper and fwhm and
|
|
1792
|
+
# broadcasts them (fwhm array disables the grouped fast path but
|
|
1793
|
+
# still uses the generic grouped C solver).
|
|
1794
|
+
flux, fluxerr, flag = sep.sum_circle_optimal(
|
|
1795
|
+
image1,
|
|
1796
|
+
x_vals[valid_pos],
|
|
1797
|
+
y_vals[valid_pos],
|
|
1798
|
+
aper_arr,
|
|
1799
|
+
fwhm=fwhm_at_pos if fwhm_at_pos is not None else fwhm_s,
|
|
1800
|
+
err=err,
|
|
1801
|
+
gain=gain if gain else 1.0,
|
|
1802
|
+
mask=mask | mask0,
|
|
1803
|
+
bkgann=bkgann_pix,
|
|
1804
|
+
grouped=group_sources,
|
|
1805
|
+
group_radius_factor=group_radius_factor,
|
|
1806
|
+
group_halo_factor=group_halo_factor,
|
|
1807
|
+
clip_sigma=clip_sigma,
|
|
1808
|
+
clip_iters=clip_iters,
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
obj['flux'][valid_pos] = flux
|
|
1812
|
+
obj['fluxerr'][valid_pos] = fluxerr
|
|
1813
|
+
obj['flags'][np.where(valid_pos)[0][flag > 0]] |= 0x200
|
|
1814
|
+
|
|
1815
|
+
else:
|
|
1816
|
+
# Standard aperture photometry
|
|
1817
|
+
log('Using standard aperture photometry')
|
|
1818
|
+
|
|
1819
|
+
flux, fluxerr, flag = sep.sum_circle(
|
|
1820
|
+
image1,
|
|
1821
|
+
x_vals[valid_pos],
|
|
1822
|
+
y_vals[valid_pos],
|
|
1823
|
+
aper_arr,
|
|
1824
|
+
err=err,
|
|
1825
|
+
gain=gain if gain else 1.0,
|
|
1826
|
+
mask=mask | mask0,
|
|
1827
|
+
bkgann=None, # Handle separately for aperture photometry
|
|
1828
|
+
clip_sigma=clip_sigma,
|
|
1829
|
+
clip_iters=clip_iters,
|
|
1830
|
+
)
|
|
1831
|
+
|
|
1832
|
+
obj['flux'][valid_pos] = flux
|
|
1833
|
+
obj['fluxerr'][valid_pos] = fluxerr
|
|
1834
|
+
obj['flags'][np.where(valid_pos)[0][flag > 0]] |= 0x200
|
|
1835
|
+
|
|
1836
|
+
# For aperture photometry, compute local background separately
|
|
1837
|
+
if bkgann_pix is not None:
|
|
1838
|
+
log('Computing sigma-clipped local background for aperture photometry')
|
|
1839
|
+
|
|
1840
|
+
bg_mean, bg_std, bg_median, bg_mad, bg_mean_clip, bg_flags = sep.stats_circann(
|
|
1841
|
+
image1,
|
|
1842
|
+
x_vals[valid_pos],
|
|
1843
|
+
y_vals[valid_pos],
|
|
1844
|
+
bkgann_pix[0],
|
|
1845
|
+
bkgann_pix[1],
|
|
1846
|
+
mask=mask | mask0,
|
|
1847
|
+
clip_sigma=clip_sigma,
|
|
1848
|
+
clip_iters=clip_iters,
|
|
1849
|
+
)
|
|
1850
|
+
|
|
1851
|
+
# Subtract local background (use median). aper_arr is either
|
|
1852
|
+
# a scalar or a per-source array, both broadcast correctly.
|
|
1853
|
+
aper_area = np.pi * np.asarray(aper_arr) ** 2
|
|
1854
|
+
obj['flux'][valid_pos] -= bg_median * aper_area
|
|
1855
|
+
|
|
1856
|
+
# Flag objects where measurement failed
|
|
1857
|
+
if psf is not None:
|
|
1858
|
+
obj['flags'][~np.isfinite(obj['flux'])] |= 0x1000
|
|
1859
|
+
else:
|
|
1860
|
+
obj['flags'][~np.isfinite(obj['flux'])] |= 0x800
|
|
1861
|
+
|
|
1862
|
+
obj = _compute_magnitudes_and_filter(obj, sn, keep_negative, log)
|
|
1863
|
+
|
|
1864
|
+
if get_bg:
|
|
1865
|
+
return obj, bg_est_bg, err
|
|
1866
|
+
else:
|
|
1867
|
+
return obj
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def _aperture_overlap_at_offset(stamp, x_n, y_n, x_t, y_t, aper, subpixel=4):
|
|
1871
|
+
"""Fraction of a placed PSF stamp that falls inside a circular aperture
|
|
1872
|
+
centred at an arbitrary position.
|
|
1873
|
+
|
|
1874
|
+
The PSF stamp is treated as already representing the source at
|
|
1875
|
+
``(x_n, y_n)`` (built via :func:`stdpipe.psf.get_psf_stamp` with the
|
|
1876
|
+
matching coordinates). The aperture has radius ``aper`` (image
|
|
1877
|
+
pixels) and centre ``(x_t, y_t)``.
|
|
1878
|
+
|
|
1879
|
+
With ``subpixel >= 2`` the per-pixel aperture coverage is computed by
|
|
1880
|
+
a ``subpixel × subpixel`` sub-grid; ``subpixel=1`` falls back to a
|
|
1881
|
+
pixel-centre mask.
|
|
1882
|
+
"""
|
|
1883
|
+
h, w = stamp.shape
|
|
1884
|
+
x0_img = int(round(x_n)) - w // 2
|
|
1885
|
+
y0_img = int(round(y_n)) - h // 2
|
|
1886
|
+
cx = float(x_t) - x0_img
|
|
1887
|
+
cy = float(y_t) - y0_img
|
|
1888
|
+
if int(subpixel) <= 1:
|
|
1889
|
+
yy, xx = np.mgrid[0:h, 0:w]
|
|
1890
|
+
rr2 = (xx - cx) ** 2 + (yy - cy) ** 2
|
|
1891
|
+
return float(np.sum(stamp[rr2 <= aper * aper]))
|
|
1892
|
+
n = int(subpixel)
|
|
1893
|
+
sub = (np.arange(n) + 0.5) / n - 0.5
|
|
1894
|
+
dyy, dxx = np.meshgrid(sub, sub, indexing='ij')
|
|
1895
|
+
yy, xx = np.mgrid[0:h, 0:w]
|
|
1896
|
+
rr2 = (
|
|
1897
|
+
(xx[:, :, None, None] - cx + dxx[None, None, :, :]) ** 2
|
|
1898
|
+
+ (yy[:, :, None, None] - cy + dyy[None, None, :, :]) ** 2
|
|
1899
|
+
)
|
|
1900
|
+
frac = np.mean(rr2 <= aper * aper, axis=(2, 3))
|
|
1901
|
+
return float(np.sum(stamp * frac))
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def measure_aperture_deblended(
|
|
1905
|
+
image, x, y, aper, psf,
|
|
1906
|
+
*,
|
|
1907
|
+
flux_seed,
|
|
1908
|
+
flux_psf,
|
|
1909
|
+
flux_psf_err=None,
|
|
1910
|
+
target=None,
|
|
1911
|
+
fwhm=None,
|
|
1912
|
+
err=None, mask=None, gain=None, bg=None,
|
|
1913
|
+
aperture_correction='ratio_field',
|
|
1914
|
+
correction_field_kwargs=None,
|
|
1915
|
+
ratio_clip=(0.5, 3.0),
|
|
1916
|
+
outlier_clip=5.0,
|
|
1917
|
+
min_for_field_fit=6,
|
|
1918
|
+
bad_flag=0x1000,
|
|
1919
|
+
propagate_neighbour_errors=True,
|
|
1920
|
+
overlap_subpixel=4,
|
|
1921
|
+
diagnostics=False,
|
|
1922
|
+
verbose=False,
|
|
1923
|
+
):
|
|
1924
|
+
"""Aperture photometry with PSF-based neighbour subtraction.
|
|
1925
|
+
|
|
1926
|
+
For every position in ``(x[target], y[target])`` measure the aperture
|
|
1927
|
+
flux on ``image``, subtract the modelled contribution of every other
|
|
1928
|
+
detection inside that aperture (using each one's PSF-scaled total
|
|
1929
|
+
flux), and add back the target's own modelled contribution. The
|
|
1930
|
+
result is an aperture flux that handles crowding the way PSF
|
|
1931
|
+
photometry would, while remaining robust to PSF-model mismatch on
|
|
1932
|
+
the target itself: PSF-model error feeds only into the small
|
|
1933
|
+
neighbour-subtraction term, partially cancelling between
|
|
1934
|
+
``model_flux`` and ``self_flux``.
|
|
1935
|
+
|
|
1936
|
+
The "total flux" used to scale each detection's PSF stamp in the
|
|
1937
|
+
neighbour model is
|
|
1938
|
+
|
|
1939
|
+
total_i = flux_seed_i * ratio(x_i, y_i)
|
|
1940
|
+
|
|
1941
|
+
where ``ratio = flux_psf / flux_seed`` is fit as a smooth field
|
|
1942
|
+
across the frame from the per-source ratios. This provides a
|
|
1943
|
+
position-dependent aperture correction even when the PSF model has
|
|
1944
|
+
smoothly varying shape.
|
|
1945
|
+
|
|
1946
|
+
Parameters
|
|
1947
|
+
----------
|
|
1948
|
+
image : 2-D ndarray
|
|
1949
|
+
Background-subtracted image (or pass the background separately
|
|
1950
|
+
via ``bg``).
|
|
1951
|
+
x, y : (N,) array-like
|
|
1952
|
+
Pixel positions of all detections in the region (targets +
|
|
1953
|
+
neighbours used only to model contamination).
|
|
1954
|
+
aper : float
|
|
1955
|
+
Aperture radius. In image pixels by default; interpreted as a
|
|
1956
|
+
multiple of ``fwhm`` when ``fwhm`` is given (matching the
|
|
1957
|
+
convention of :func:`measure_objects`).
|
|
1958
|
+
psf : dict
|
|
1959
|
+
PSF model as returned by :func:`stdpipe.psf.run_psfex`,
|
|
1960
|
+
:func:`stdpipe.psf.load_psf`, or
|
|
1961
|
+
:func:`stdpipe.psf.create_psf_model`. May be position-dependent.
|
|
1962
|
+
flux_seed : (N,) array-like
|
|
1963
|
+
Per-source aperture flux from the upstream detection pass. Used
|
|
1964
|
+
to anchor the smooth aperture-correction ratio field.
|
|
1965
|
+
flux_psf : (N,) array-like
|
|
1966
|
+
Per-source PSF-fitted flux from the upstream PSF-fitting pass.
|
|
1967
|
+
Used to anchor the ratio field and to scale neighbour PSF stamps
|
|
1968
|
+
in the model image.
|
|
1969
|
+
flux_psf_err : (N,) array-like, optional
|
|
1970
|
+
Per-source PSF-fit flux errors. Required when
|
|
1971
|
+
``propagate_neighbour_errors=True``.
|
|
1972
|
+
target : (N,) bool mask or array of int indices, optional
|
|
1973
|
+
Which detections to measure. Detections not in ``target`` still
|
|
1974
|
+
appear in the neighbour model. Default: every detection.
|
|
1975
|
+
fwhm : float, optional
|
|
1976
|
+
Source FWHM in pixels. When given, ``aper`` is interpreted as a
|
|
1977
|
+
multiple of FWHM.
|
|
1978
|
+
err : 2-D ndarray, optional
|
|
1979
|
+
Per-pixel 1-sigma error map for the input image. Used by
|
|
1980
|
+
``sep.sum_circle`` to compute the aperture noise.
|
|
1981
|
+
mask : 2-D bool ndarray, optional
|
|
1982
|
+
Bad-pixel mask. ``True`` pixels are excluded from aperture sums.
|
|
1983
|
+
gain : float, optional
|
|
1984
|
+
Detector gain in e-/ADU; forwarded to ``sep.sum_circle`` for
|
|
1985
|
+
Poisson noise on the aperture sum.
|
|
1986
|
+
bg : 2-D ndarray, optional
|
|
1987
|
+
Background map. If given, it is subtracted from ``image`` before
|
|
1988
|
+
the aperture sums (so ``image`` itself need not be background-
|
|
1989
|
+
subtracted).
|
|
1990
|
+
aperture_correction : {'ratio_field', 'fixed', 'none'}
|
|
1991
|
+
How to map ``flux_seed`` to "total flux" when scaling neighbour
|
|
1992
|
+
PSF stamps. ``'ratio_field'`` fits a smooth 2-D field of the
|
|
1993
|
+
per-source PSF/seed ratio (default; see ``correction_field_kwargs``
|
|
1994
|
+
and ``min_for_field_fit``); ``'fixed'`` uses a single MAD-clipped
|
|
1995
|
+
median ratio; ``'none'`` uses ``flux_seed`` as the total flux.
|
|
1996
|
+
correction_field_kwargs : dict, optional
|
|
1997
|
+
Forwarded to :func:`stdpipe.smoothing.fit_vector_field_2d`. The
|
|
1998
|
+
default uses the LOESS backend with ``k=100`` and per-axis
|
|
1999
|
+
scales chosen from the spread of the input positions, which is a
|
|
2000
|
+
quality upgrade over the rigid 2-D quadratic used in earlier
|
|
2001
|
+
prototypes.
|
|
2002
|
+
ratio_clip : (lo, hi)
|
|
2003
|
+
Hard limits applied to the modelled ratio at every position to
|
|
2004
|
+
guard against extrapolation pathologies.
|
|
2005
|
+
outlier_clip : float
|
|
2006
|
+
MAD multiple used to reject outliers from the per-source ratio
|
|
2007
|
+
sample before fitting the field.
|
|
2008
|
+
min_for_field_fit : int
|
|
2009
|
+
Minimum number of clean ratios required to fit the smooth field;
|
|
2010
|
+
below this, the median ratio is used everywhere.
|
|
2011
|
+
bad_flag : int
|
|
2012
|
+
Flag bit OR'ed into the output ``flags`` for measurements that
|
|
2013
|
+
could not be computed.
|
|
2014
|
+
propagate_neighbour_errors : bool
|
|
2015
|
+
If True (default), inflate ``fluxerr`` by the quadrature sum of
|
|
2016
|
+
each near neighbour's PSF-flux error scaled by its overlap with
|
|
2017
|
+
the target aperture. Requires ``flux_psf_err``.
|
|
2018
|
+
overlap_subpixel : int
|
|
2019
|
+
Sub-pixel sampling factor for the per-neighbour aperture overlap
|
|
2020
|
+
integrals. ``1`` for a faster pixel-centre mask.
|
|
2021
|
+
diagnostics : bool
|
|
2022
|
+
If True, attach extra columns to the output table:
|
|
2023
|
+
``flux_ap_raw``, ``flux_model``, ``flux_total_model``,
|
|
2024
|
+
``flux_ratio_model``, ``self_frac``, ``flux_neighbour_err``.
|
|
2025
|
+
verbose : bool or callable
|
|
2026
|
+
Boolean to enable default ``print`` logging, or a print-like
|
|
2027
|
+
callable.
|
|
2028
|
+
|
|
2029
|
+
Returns
|
|
2030
|
+
-------
|
|
2031
|
+
astropy.table.Table
|
|
2032
|
+
One row per measured target, in the order they appeared in the
|
|
2033
|
+
input. Columns: ``x``, ``y``, ``flux``, ``fluxerr``, ``mag``,
|
|
2034
|
+
``magerr``, ``flags``. Diagnostic columns when
|
|
2035
|
+
``diagnostics=True``.
|
|
2036
|
+
"""
|
|
2037
|
+
from astropy.table import Table
|
|
2038
|
+
from . import psf as psf_mod
|
|
2039
|
+
from . import smoothing
|
|
2040
|
+
|
|
2041
|
+
log = (verbose if callable(verbose) else print) if verbose else (
|
|
2042
|
+
lambda *a, **k: None
|
|
2043
|
+
)
|
|
2044
|
+
|
|
2045
|
+
x = np.asarray(x, dtype=float)
|
|
2046
|
+
y = np.asarray(y, dtype=float)
|
|
2047
|
+
flux_seed = np.asarray(flux_seed, dtype=float)
|
|
2048
|
+
flux_psf = np.asarray(flux_psf, dtype=float)
|
|
2049
|
+
if x.shape != y.shape or x.shape != flux_seed.shape \
|
|
2050
|
+
or x.shape != flux_psf.shape:
|
|
2051
|
+
raise ValueError(
|
|
2052
|
+
"x, y, flux_seed, flux_psf must all have the same shape"
|
|
2053
|
+
)
|
|
2054
|
+
n_all = x.size
|
|
2055
|
+
|
|
2056
|
+
if propagate_neighbour_errors and flux_psf_err is None:
|
|
2057
|
+
raise ValueError(
|
|
2058
|
+
"flux_psf_err is required when propagate_neighbour_errors=True"
|
|
2059
|
+
)
|
|
2060
|
+
if flux_psf_err is not None:
|
|
2061
|
+
flux_psf_err = np.asarray(flux_psf_err, dtype=float)
|
|
2062
|
+
if flux_psf_err.shape != x.shape:
|
|
2063
|
+
raise ValueError("flux_psf_err must have the same shape as x")
|
|
2064
|
+
|
|
2065
|
+
if target is None:
|
|
2066
|
+
target_idx = np.arange(n_all, dtype=int)
|
|
2067
|
+
else:
|
|
2068
|
+
target = np.asarray(target)
|
|
2069
|
+
if target.dtype == bool:
|
|
2070
|
+
if target.shape != x.shape:
|
|
2071
|
+
raise ValueError("target mask must match the shape of x")
|
|
2072
|
+
target_idx = np.where(target)[0]
|
|
2073
|
+
else:
|
|
2074
|
+
target_idx = target.astype(int)
|
|
2075
|
+
|
|
2076
|
+
aper_pix = float(aper) * float(fwhm) if fwhm is not None else float(aper)
|
|
2077
|
+
|
|
2078
|
+
# ------------------------------------------------------------------
|
|
2079
|
+
# 1. Per-source ratio = flux_psf / flux_seed, MAD-clipped sample.
|
|
2080
|
+
# ------------------------------------------------------------------
|
|
2081
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
2082
|
+
ratio = flux_psf / flux_seed
|
|
2083
|
+
ratio_ok = (
|
|
2084
|
+
np.isfinite(ratio) & (ratio > 0)
|
|
2085
|
+
& np.isfinite(flux_seed) & (flux_seed > 0)
|
|
2086
|
+
& np.isfinite(flux_psf) & (flux_psf > 0)
|
|
2087
|
+
)
|
|
2088
|
+
n_clean_ratio = int(ratio_ok.sum())
|
|
2089
|
+
if n_clean_ratio:
|
|
2090
|
+
ratio_med = float(np.nanmedian(ratio[ratio_ok]))
|
|
2091
|
+
ratio_mad = float(np.nan if n_clean_ratio < 2
|
|
2092
|
+
else _astropy_mad_std(ratio[ratio_ok]))
|
|
2093
|
+
if np.isfinite(ratio_mad) and ratio_mad > 0:
|
|
2094
|
+
ratio_ok &= np.abs(ratio - ratio_med) < outlier_clip * ratio_mad
|
|
2095
|
+
else:
|
|
2096
|
+
ratio_med = float('nan')
|
|
2097
|
+
|
|
2098
|
+
# ------------------------------------------------------------------
|
|
2099
|
+
# 2. Smooth ratio field (or fall back to fixed median).
|
|
2100
|
+
# ------------------------------------------------------------------
|
|
2101
|
+
use_field = (
|
|
2102
|
+
aperture_correction == 'ratio_field'
|
|
2103
|
+
and int(ratio_ok.sum()) >= int(min_for_field_fit)
|
|
2104
|
+
)
|
|
2105
|
+
if use_field:
|
|
2106
|
+
kwargs = dict(correction_field_kwargs or {})
|
|
2107
|
+
kwargs.setdefault('backend', 'loess')
|
|
2108
|
+
if kwargs['backend'] == 'loess':
|
|
2109
|
+
xs = float(np.ptp(x[ratio_ok])) or 1.0
|
|
2110
|
+
ys = float(np.ptp(y[ratio_ok])) or 1.0
|
|
2111
|
+
kwargs.setdefault('scales', (xs / 4.0, ys / 4.0))
|
|
2112
|
+
kwargs.setdefault('k', min(100, int(ratio_ok.sum())))
|
|
2113
|
+
ratio_pred_fn = smoothing.fit_vector_field_2d(
|
|
2114
|
+
x[ratio_ok], y[ratio_ok], ratio[ratio_ok], **kwargs
|
|
2115
|
+
)
|
|
2116
|
+
log("aperture-correction field fit on %d sources" % int(ratio_ok.sum()))
|
|
2117
|
+
elif aperture_correction == 'none':
|
|
2118
|
+
def ratio_pred_fn(xq, yq):
|
|
2119
|
+
return np.ones_like(np.asarray(xq, float))
|
|
2120
|
+
else:
|
|
2121
|
+
if aperture_correction == 'ratio_field':
|
|
2122
|
+
log("ratio-field fallback: only %d clean ratios, using fixed median"
|
|
2123
|
+
% int(ratio_ok.sum()))
|
|
2124
|
+
const = ratio_med if np.isfinite(ratio_med) else 1.0
|
|
2125
|
+
def ratio_pred_fn(xq, yq):
|
|
2126
|
+
return np.full(np.asarray(xq, float).shape, const)
|
|
2127
|
+
|
|
2128
|
+
model_ratio_all = np.asarray(ratio_pred_fn(x, y), float)
|
|
2129
|
+
bad_ratio = ~np.isfinite(model_ratio_all)
|
|
2130
|
+
if bad_ratio.any():
|
|
2131
|
+
fill = ratio_med if np.isfinite(ratio_med) else 1.0
|
|
2132
|
+
model_ratio_all[bad_ratio] = fill
|
|
2133
|
+
lo, hi = ratio_clip
|
|
2134
|
+
np.clip(model_ratio_all, lo, hi, out=model_ratio_all)
|
|
2135
|
+
|
|
2136
|
+
total_flux = flux_seed * model_ratio_all
|
|
2137
|
+
|
|
2138
|
+
# ------------------------------------------------------------------
|
|
2139
|
+
# 3. Build the neighbour model image.
|
|
2140
|
+
# ------------------------------------------------------------------
|
|
2141
|
+
image_in = np.ascontiguousarray(image, dtype=np.double)
|
|
2142
|
+
if bg is not None:
|
|
2143
|
+
image_in = image_in - np.asarray(bg, dtype=np.double)
|
|
2144
|
+
model_image = np.zeros_like(image_in)
|
|
2145
|
+
place_ok = (
|
|
2146
|
+
np.isfinite(total_flux) & (total_flux > 0)
|
|
2147
|
+
& np.isfinite(x) & np.isfinite(y)
|
|
2148
|
+
)
|
|
2149
|
+
for i in np.where(place_ok)[0]:
|
|
2150
|
+
psf_mod.place_psf_stamp(
|
|
2151
|
+
model_image, psf, float(x[i]), float(y[i]),
|
|
2152
|
+
flux=float(total_flux[i]),
|
|
2153
|
+
)
|
|
2154
|
+
|
|
2155
|
+
# ------------------------------------------------------------------
|
|
2156
|
+
# 4. Aperture sums on real and model images at each target.
|
|
2157
|
+
# ------------------------------------------------------------------
|
|
2158
|
+
x_t = x[target_idx]
|
|
2159
|
+
y_t = y[target_idx]
|
|
2160
|
+
err_in = np.ascontiguousarray(err, dtype=np.double) if err is not None else None
|
|
2161
|
+
mask_in = np.ascontiguousarray(mask, dtype=bool) if mask is not None else None
|
|
2162
|
+
raw_flux, raw_fluxerr, raw_flag = sep.sum_circle(
|
|
2163
|
+
image_in, x_t, y_t, aper_pix,
|
|
2164
|
+
err=err_in, gain=gain, mask=mask_in,
|
|
2165
|
+
)
|
|
2166
|
+
model_flux, _, _ = sep.sum_circle(model_image, x_t, y_t, aper_pix)
|
|
2167
|
+
|
|
2168
|
+
# ------------------------------------------------------------------
|
|
2169
|
+
# 5. Self flux = total_flux_target * enclosed_psf_fraction(target).
|
|
2170
|
+
# ------------------------------------------------------------------
|
|
2171
|
+
self_frac = np.full(target_idx.size, np.nan)
|
|
2172
|
+
for k, i in enumerate(target_idx):
|
|
2173
|
+
if not place_ok[i]:
|
|
2174
|
+
continue
|
|
2175
|
+
try:
|
|
2176
|
+
self_frac[k] = psf_mod.enclosed_psf_fraction(
|
|
2177
|
+
psf, float(x[i]), float(y[i]),
|
|
2178
|
+
radius=aper_pix, subpixel=overlap_subpixel,
|
|
2179
|
+
)
|
|
2180
|
+
except Exception:
|
|
2181
|
+
pass
|
|
2182
|
+
self_flux = total_flux[target_idx] * self_frac
|
|
2183
|
+
|
|
2184
|
+
corr_flux = raw_flux - model_flux + self_flux
|
|
2185
|
+
|
|
2186
|
+
# ------------------------------------------------------------------
|
|
2187
|
+
# 6. Optional per-target neighbour error in quadrature.
|
|
2188
|
+
# ------------------------------------------------------------------
|
|
2189
|
+
neighbour_err = np.zeros(target_idx.size, dtype=float)
|
|
2190
|
+
if propagate_neighbour_errors and flux_psf_err is not None:
|
|
2191
|
+
# PSF radius in image pixels (rough): half the stamp size.
|
|
2192
|
+
psf_radius_pix = 0.5 * float(psf['width'] * psf['sampling'])
|
|
2193
|
+
search_r = psf_radius_pix + aper_pix
|
|
2194
|
+
from scipy.spatial import cKDTree
|
|
2195
|
+
tree = cKDTree(np.c_[x, y])
|
|
2196
|
+
near_lists = tree.query_ball_point(
|
|
2197
|
+
np.c_[x_t, y_t], r=search_r,
|
|
2198
|
+
)
|
|
2199
|
+
# Per-source PSF-fit flux error scaled to the same total-flux
|
|
2200
|
+
# units we placed the stamps in.
|
|
2201
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
2202
|
+
ratio_for_err = np.where(
|
|
2203
|
+
flux_psf > 0,
|
|
2204
|
+
model_ratio_all * flux_seed / flux_psf,
|
|
2205
|
+
model_ratio_all,
|
|
2206
|
+
)
|
|
2207
|
+
ratio_for_err = np.where(
|
|
2208
|
+
np.isfinite(ratio_for_err), ratio_for_err, 1.0
|
|
2209
|
+
)
|
|
2210
|
+
total_flux_err = np.abs(flux_psf_err) * np.abs(ratio_for_err)
|
|
2211
|
+
for k, i in enumerate(target_idx):
|
|
2212
|
+
var = 0.0
|
|
2213
|
+
for j in near_lists[k]:
|
|
2214
|
+
if j == i:
|
|
2215
|
+
continue
|
|
2216
|
+
if not place_ok[j] or not np.isfinite(total_flux_err[j]):
|
|
2217
|
+
continue
|
|
2218
|
+
stamp = psf_mod.get_psf_stamp(
|
|
2219
|
+
psf, float(x[j]), float(y[j]), normalize=True,
|
|
2220
|
+
)
|
|
2221
|
+
overlap = _aperture_overlap_at_offset(
|
|
2222
|
+
stamp, float(x[j]), float(y[j]),
|
|
2223
|
+
float(x_t[k]), float(y_t[k]), aper_pix,
|
|
2224
|
+
subpixel=overlap_subpixel,
|
|
2225
|
+
)
|
|
2226
|
+
var += (total_flux_err[j] * overlap) ** 2
|
|
2227
|
+
neighbour_err[k] = np.sqrt(var)
|
|
2228
|
+
fluxerr = np.sqrt(np.asarray(raw_fluxerr, float) ** 2 + neighbour_err ** 2)
|
|
2229
|
+
else:
|
|
2230
|
+
fluxerr = np.asarray(raw_fluxerr, float)
|
|
2231
|
+
|
|
2232
|
+
# ------------------------------------------------------------------
|
|
2233
|
+
# 7. Output table + flags + magnitudes.
|
|
2234
|
+
# ------------------------------------------------------------------
|
|
2235
|
+
out = Table()
|
|
2236
|
+
out['x'] = x_t
|
|
2237
|
+
out['y'] = y_t
|
|
2238
|
+
out['flux'] = corr_flux
|
|
2239
|
+
out['fluxerr'] = fluxerr
|
|
2240
|
+
out['flags'] = np.asarray(raw_flag, int).copy()
|
|
2241
|
+
bad = (
|
|
2242
|
+
~np.isfinite(corr_flux)
|
|
2243
|
+
| ~np.isfinite(fluxerr)
|
|
2244
|
+
| (corr_flux <= 0)
|
|
2245
|
+
| (np.asarray(raw_flag, int) > 0)
|
|
2246
|
+
)
|
|
2247
|
+
out['flags'][bad] |= int(bad_flag)
|
|
2248
|
+
out['mag'] = np.full(target_idx.size, np.nan)
|
|
2249
|
+
out['magerr'] = np.full(target_idx.size, np.nan)
|
|
2250
|
+
good = ~bad
|
|
2251
|
+
out['mag'][good] = -2.5 * np.log10(corr_flux[good])
|
|
2252
|
+
out['magerr'][good] = (2.5 / np.log(10)) * fluxerr[good] / corr_flux[good]
|
|
2253
|
+
|
|
2254
|
+
if diagnostics:
|
|
2255
|
+
out['flux_ap_raw'] = np.asarray(raw_flux, float)
|
|
2256
|
+
out['flux_model'] = np.asarray(model_flux, float)
|
|
2257
|
+
out['flux_total_model'] = total_flux[target_idx]
|
|
2258
|
+
out['flux_ratio_model'] = model_ratio_all[target_idx]
|
|
2259
|
+
out['self_frac'] = self_frac
|
|
2260
|
+
if propagate_neighbour_errors:
|
|
2261
|
+
out['flux_neighbour_err'] = neighbour_err
|
|
2262
|
+
|
|
2263
|
+
return out
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def _astropy_mad_std(x):
|
|
2267
|
+
from astropy.stats import mad_std
|
|
2268
|
+
return float(mad_std(x))
|