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,1124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Routines for PSF photometry using photutils.
|
|
3
|
+
|
|
4
|
+
This module provides PSF fitting photometry as an alternative to aperture
|
|
5
|
+
photometry, which is more accurate for point sources especially in crowded
|
|
6
|
+
fields or when PSF wings are significant.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from astropy.table import Table
|
|
11
|
+
from astropy.nddata import NDData
|
|
12
|
+
from astropy.stats import sigma_clipped_stats
|
|
13
|
+
|
|
14
|
+
import photutils
|
|
15
|
+
import photutils.background
|
|
16
|
+
import photutils.psf
|
|
17
|
+
from photutils.utils import calc_total_error
|
|
18
|
+
|
|
19
|
+
from . import photometry as phot
|
|
20
|
+
from . import psf as psf_module
|
|
21
|
+
from .photometry_measure import _is_callable_fwhm, _fwhm_median
|
|
22
|
+
|
|
23
|
+
# Re-export for backward compatibility
|
|
24
|
+
from .psf import create_psf_model
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _odd_int(value, min_value=1):
|
|
28
|
+
value = int(np.round(value))
|
|
29
|
+
if value < min_value:
|
|
30
|
+
value = min_value
|
|
31
|
+
if value % 2 == 0:
|
|
32
|
+
value += 1
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compute_oversampling(psf_sampling):
|
|
37
|
+
if psf_sampling is None or not np.isfinite(psf_sampling) or psf_sampling <= 0:
|
|
38
|
+
return 1
|
|
39
|
+
if psf_sampling >= 1.0:
|
|
40
|
+
return 1
|
|
41
|
+
return max(1, int(np.rint(1.0 / psf_sampling)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compute_native_psf_size(psf_height, psf_sampling):
|
|
45
|
+
if psf_sampling is None or not np.isfinite(psf_sampling) or psf_sampling <= 0:
|
|
46
|
+
size = psf_height
|
|
47
|
+
elif psf_sampling >= 1.0:
|
|
48
|
+
size = psf_height
|
|
49
|
+
else:
|
|
50
|
+
size = psf_height * psf_sampling
|
|
51
|
+
return _odd_int(size)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _scale_psf_image_for_photutils(psf_image, oversampling):
|
|
55
|
+
if oversampling is None:
|
|
56
|
+
return psf_image
|
|
57
|
+
factor = float(oversampling) ** 2
|
|
58
|
+
if factor <= 1.0:
|
|
59
|
+
return psf_image
|
|
60
|
+
return psf_image * factor
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _compute_psf_quality_columns(
|
|
64
|
+
phot_obj,
|
|
65
|
+
psf_model,
|
|
66
|
+
image1,
|
|
67
|
+
err,
|
|
68
|
+
mask_combined,
|
|
69
|
+
x_fit,
|
|
70
|
+
y_fit,
|
|
71
|
+
flux_fit,
|
|
72
|
+
fit_size,
|
|
73
|
+
fwhm,
|
|
74
|
+
log,
|
|
75
|
+
):
|
|
76
|
+
"""Build per-source residual stamps and compute crowdsource-style quality
|
|
77
|
+
metrics (``qf``, ``fracflux``, ``spread_model``, ``dspread_model``) from the
|
|
78
|
+
output of a successful :class:`photutils.psf.PSFPhotometry` run.
|
|
79
|
+
|
|
80
|
+
Returns a dict of length-``N`` arrays, or ``None`` if anything fails.
|
|
81
|
+
"""
|
|
82
|
+
from . import photometry_quality as pq
|
|
83
|
+
|
|
84
|
+
N = len(x_fit)
|
|
85
|
+
if N == 0:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
H, W = image1.shape
|
|
89
|
+
half = int(fit_size) // 2
|
|
90
|
+
sz = 2 * half + 1 # ensure odd
|
|
91
|
+
|
|
92
|
+
# NB: the model image excludes the fitted local background, while the data
|
|
93
|
+
# stamps below still contain it when ``bkgann`` is used — any residual
|
|
94
|
+
# local background therefore leaks into ``fracflux`` / ``spread_model``.
|
|
95
|
+
try:
|
|
96
|
+
full_model = phot_obj.make_model_image(
|
|
97
|
+
image1.shape, psf_shape=(sz, sz), include_localbkg=False
|
|
98
|
+
)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
log('Skipping PSF quality stats (model image build failed: %s)' % e)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
weight_full = np.zeros_like(image1, dtype='f4')
|
|
104
|
+
if err is not None:
|
|
105
|
+
finite = np.isfinite(err) & (err > 0)
|
|
106
|
+
weight_full[finite] = 1.0 / err[finite]
|
|
107
|
+
if mask_combined is not None:
|
|
108
|
+
weight_full[mask_combined.astype(bool)] = 0.0
|
|
109
|
+
|
|
110
|
+
impsf_stack = np.zeros((N, sz, sz), dtype='f4')
|
|
111
|
+
im_stack = np.zeros((N, sz, sz), dtype='f4')
|
|
112
|
+
psf_stack = np.zeros((N, sz, sz), dtype='f4')
|
|
113
|
+
weight_stack = np.zeros((N, sz, sz), dtype='f4')
|
|
114
|
+
|
|
115
|
+
x_fit = np.asarray(x_fit, dtype='f4')
|
|
116
|
+
y_fit = np.asarray(y_fit, dtype='f4')
|
|
117
|
+
flux_fit = np.asarray(flux_fit, dtype='f4')
|
|
118
|
+
valid = np.isfinite(x_fit) & np.isfinite(y_fit) & np.isfinite(flux_fit)
|
|
119
|
+
filled = np.zeros(N, dtype=bool)
|
|
120
|
+
|
|
121
|
+
for i in range(N):
|
|
122
|
+
if not valid[i]:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
cx = int(np.round(x_fit[i]))
|
|
126
|
+
cy = int(np.round(y_fit[i]))
|
|
127
|
+
x0, x1 = cx - half, cx + half + 1
|
|
128
|
+
y0, y1 = cy - half, cy + half + 1
|
|
129
|
+
|
|
130
|
+
sx0, sx1 = max(0, x0), min(W, x1)
|
|
131
|
+
sy0, sy1 = max(0, y0), min(H, y1)
|
|
132
|
+
if sx1 <= sx0 or sy1 <= sy0:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
lx0, lx1 = sx0 - x0, sx1 - x0
|
|
136
|
+
ly0, ly1 = sy0 - y0, sy1 - y0
|
|
137
|
+
|
|
138
|
+
yy = np.arange(sy0, sy1, dtype='f4').reshape(-1, 1)
|
|
139
|
+
xx = np.arange(sx0, sx1, dtype='f4').reshape(1, -1)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
m = psf_model.copy()
|
|
143
|
+
if hasattr(m, 'x_0'):
|
|
144
|
+
m.x_0 = float(x_fit[i])
|
|
145
|
+
if hasattr(m, 'y_0'):
|
|
146
|
+
m.y_0 = float(y_fit[i])
|
|
147
|
+
if hasattr(m, 'flux'):
|
|
148
|
+
m.flux = 1.0
|
|
149
|
+
psf_local = np.asarray(m(xx, yy), dtype='f4')
|
|
150
|
+
except Exception:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
single_local = psf_local * float(flux_fit[i])
|
|
154
|
+
data_local = image1[sy0:sy1, sx0:sx1].astype('f4')
|
|
155
|
+
model_local = full_model[sy0:sy1, sx0:sx1].astype('f4')
|
|
156
|
+
|
|
157
|
+
psf_stack[i, ly0:ly1, lx0:lx1] = psf_local
|
|
158
|
+
im_stack[i, ly0:ly1, lx0:lx1] = data_local
|
|
159
|
+
weight_stack[i, ly0:ly1, lx0:lx1] = weight_full[sy0:sy1, sx0:sx1]
|
|
160
|
+
# neighbour-subtracted = data - (full_model - this_source) = data - full_model + this_source
|
|
161
|
+
impsf_stack[i, ly0:ly1, lx0:lx1] = data_local - model_local + single_local
|
|
162
|
+
filled[i] = True
|
|
163
|
+
|
|
164
|
+
# Per-source FWHM (FWHMMap callable, scalar, or None)
|
|
165
|
+
if _is_callable_fwhm(fwhm):
|
|
166
|
+
fwhm_arr = np.asarray(fwhm(x_fit, y_fit), dtype='f4')
|
|
167
|
+
elif fwhm is not None:
|
|
168
|
+
fwhm_arr = float(fwhm)
|
|
169
|
+
else:
|
|
170
|
+
fwhm_arr = None
|
|
171
|
+
|
|
172
|
+
quality = pq.compute_psf_quality(
|
|
173
|
+
impsf_stack, im_stack, psf_stack, weight_stack, fwhm=fwhm_arr
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Sources without a stamp (failed fit, off-image, model evaluation error)
|
|
177
|
+
# would otherwise get zeros from their empty stamps — and spread_model == 0
|
|
178
|
+
# reads as a clean star. Report NaN for them instead.
|
|
179
|
+
for key in quality:
|
|
180
|
+
quality[key] = np.asarray(quality[key], dtype='f4')
|
|
181
|
+
quality[key][~filled] = np.nan
|
|
182
|
+
|
|
183
|
+
return quality
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class GradientLocalBackground(photutils.background.LocalBackground):
|
|
187
|
+
"""
|
|
188
|
+
Local background estimator using gradient fitting with sigma-clipping.
|
|
189
|
+
|
|
190
|
+
Inherits from photutils.background.LocalBackground but overrides the estimation
|
|
191
|
+
method to fit polynomial gradients instead of taking mean/median.
|
|
192
|
+
|
|
193
|
+
Instead of taking mean/median of annulus (assumes flat background),
|
|
194
|
+
fits a polynomial model to the annulus and evaluates at source position.
|
|
195
|
+
Includes sigma-clipping to reject outliers (contaminating sources).
|
|
196
|
+
|
|
197
|
+
This dramatically reduces biases with background gradients:
|
|
198
|
+
- Linear gradients: ~20× improvement (19% → <1% error)
|
|
199
|
+
- Quadratic gradients: ~100-400× improvement (-415% → <5% error)
|
|
200
|
+
- Sigma-clipping provides robustness in crowded fields
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
inner_radius : float
|
|
205
|
+
Inner radius of annulus in pixels
|
|
206
|
+
outer_radius : float
|
|
207
|
+
Outer radius of annulus in pixels
|
|
208
|
+
order : int, optional
|
|
209
|
+
Polynomial order:
|
|
210
|
+
0 = constant (mean, equivalent to standard LocalBackground)
|
|
211
|
+
1 = plane (linear gradient, recommended)
|
|
212
|
+
2 = quadratic surface (complex gradients)
|
|
213
|
+
Default is 1.
|
|
214
|
+
sigma : float, optional
|
|
215
|
+
Sigma threshold for sigma-clipping outliers. Default is 3.0.
|
|
216
|
+
Higher values are more permissive, lower values reject more outliers.
|
|
217
|
+
maxiters : int, optional
|
|
218
|
+
Maximum number of sigma-clipping iterations. Default is 3.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, inner_radius, outer_radius, order=1, sigma=3.0, maxiters=3):
|
|
222
|
+
# Initialize parent class with dummy bkg_estimator (we'll override __call__)
|
|
223
|
+
super().__init__(inner_radius, outer_radius, bkg_estimator=None)
|
|
224
|
+
self.order = order
|
|
225
|
+
self.sigma = sigma
|
|
226
|
+
self.maxiters = maxiters
|
|
227
|
+
|
|
228
|
+
def __call__(self, data, x, y, mask=None):
|
|
229
|
+
"""
|
|
230
|
+
Estimate local background at position(s) (x, y).
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
data : 2D ndarray
|
|
235
|
+
Image data
|
|
236
|
+
x, y : float or array-like
|
|
237
|
+
Source position(s)
|
|
238
|
+
mask : 2D bool ndarray, optional
|
|
239
|
+
Mask (True = masked)
|
|
240
|
+
|
|
241
|
+
Returns
|
|
242
|
+
-------
|
|
243
|
+
bg : float or ndarray
|
|
244
|
+
Background value(s) at source position(s)
|
|
245
|
+
"""
|
|
246
|
+
# Handle scalar vs array input
|
|
247
|
+
x = np.atleast_1d(x)
|
|
248
|
+
y = np.atleast_1d(y)
|
|
249
|
+
scalar_input = len(x) == 1
|
|
250
|
+
|
|
251
|
+
if mask is None:
|
|
252
|
+
mask = np.zeros_like(data, dtype=bool)
|
|
253
|
+
|
|
254
|
+
bg_values = np.zeros(len(x))
|
|
255
|
+
|
|
256
|
+
size_y, size_x = data.shape
|
|
257
|
+
|
|
258
|
+
for i, (xi, yi) in enumerate(zip(x, y)):
|
|
259
|
+
# Define bounding box for annulus region (OPTIMIZATION: avoid full-image arrays)
|
|
260
|
+
r_outer = self.outer_radius
|
|
261
|
+
x0 = max(0, int(xi - r_outer) - 1)
|
|
262
|
+
x1 = min(size_x, int(xi + r_outer) + 2)
|
|
263
|
+
y0 = max(0, int(yi - r_outer) - 1)
|
|
264
|
+
y1 = min(size_y, int(yi + r_outer) + 2)
|
|
265
|
+
|
|
266
|
+
# Create coordinate grids ONLY for bounding box (not full image)
|
|
267
|
+
yy, xx = np.mgrid[y0:y1, x0:x1]
|
|
268
|
+
|
|
269
|
+
# Distance from source (only compute for bounding box pixels)
|
|
270
|
+
dist = np.sqrt((xx - xi) ** 2 + (yy - yi) ** 2)
|
|
271
|
+
|
|
272
|
+
# Annulus mask
|
|
273
|
+
annulus_mask = (dist >= self.inner_radius) & (dist <= self.outer_radius)
|
|
274
|
+
if mask is not None:
|
|
275
|
+
annulus_mask &= ~mask[y0:y1, x0:x1]
|
|
276
|
+
|
|
277
|
+
if not np.any(annulus_mask):
|
|
278
|
+
# Fallback: return median of unmasked data
|
|
279
|
+
bg_values[i] = np.median(data[~mask]) if np.any(~mask) else 0.0
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
# Get annulus data (extract from bounding box)
|
|
283
|
+
x_annulus = xx[annulus_mask].ravel()
|
|
284
|
+
y_annulus = yy[annulus_mask].ravel()
|
|
285
|
+
data_bbox = data[y0:y1, x0:x1]
|
|
286
|
+
z_annulus = data_bbox[annulus_mask].ravel()
|
|
287
|
+
|
|
288
|
+
# Remove NaN/Inf
|
|
289
|
+
valid = np.isfinite(z_annulus)
|
|
290
|
+
x_annulus = x_annulus[valid]
|
|
291
|
+
y_annulus = y_annulus[valid]
|
|
292
|
+
z_annulus = z_annulus[valid]
|
|
293
|
+
|
|
294
|
+
if len(z_annulus) < max(10, (self.order + 1) * (self.order + 2) // 2):
|
|
295
|
+
# Not enough points, fallback to mean
|
|
296
|
+
bg_values[i] = np.mean(z_annulus) if len(z_annulus) > 0 else 0.0
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
# Sigma-clipping to reject outliers (contaminating sources)
|
|
300
|
+
# Iteratively fit, compute residuals, reject outliers, refit
|
|
301
|
+
good_mask = np.ones(len(z_annulus), dtype=bool)
|
|
302
|
+
|
|
303
|
+
for iteration in range(self.maxiters):
|
|
304
|
+
x_good = x_annulus[good_mask]
|
|
305
|
+
y_good = y_annulus[good_mask]
|
|
306
|
+
z_good = z_annulus[good_mask]
|
|
307
|
+
|
|
308
|
+
if len(z_good) < max(10, (self.order + 1) * (self.order + 2) // 2):
|
|
309
|
+
# Too many rejections, stop
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
# Fit current good points. ``coeffs`` stays None when the
|
|
313
|
+
# polynomial fit was not performed or failed, so the outlier
|
|
314
|
+
# rejection below falls back to the constant ``bg_fit``.
|
|
315
|
+
coeffs = None
|
|
316
|
+
|
|
317
|
+
if self.order == 0:
|
|
318
|
+
# Constant (mean)
|
|
319
|
+
bg_fit = np.mean(z_good)
|
|
320
|
+
residuals = z_good - bg_fit
|
|
321
|
+
|
|
322
|
+
elif self.order == 1:
|
|
323
|
+
# Plane: z = a + b*(x-x0) + c*(y-y0)
|
|
324
|
+
dx = x_good - xi
|
|
325
|
+
dy = y_good - yi
|
|
326
|
+
A = np.column_stack([np.ones_like(x_good), dx, dy])
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
coeffs = np.linalg.lstsq(A, z_good, rcond=None)[0]
|
|
330
|
+
residuals = z_good - (coeffs[0] + coeffs[1] * dx + coeffs[2] * dy)
|
|
331
|
+
except np.linalg.LinAlgError:
|
|
332
|
+
bg_fit = np.mean(z_good)
|
|
333
|
+
residuals = z_good - bg_fit
|
|
334
|
+
|
|
335
|
+
elif self.order == 2:
|
|
336
|
+
# Quadratic: z = a + b*dx + c*dy + d*dx^2 + e*dy^2 + f*dx*dy
|
|
337
|
+
dx = x_good - xi
|
|
338
|
+
dy = y_good - yi
|
|
339
|
+
A = np.column_stack([np.ones_like(x_good), dx, dy, dx**2, dy**2, dx * dy])
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
coeffs = np.linalg.lstsq(A, z_good, rcond=None)[0]
|
|
343
|
+
residuals = z_good - (
|
|
344
|
+
coeffs[0]
|
|
345
|
+
+ coeffs[1] * dx
|
|
346
|
+
+ coeffs[2] * dy
|
|
347
|
+
+ coeffs[3] * dx**2
|
|
348
|
+
+ coeffs[4] * dy**2
|
|
349
|
+
+ coeffs[5] * dx * dy
|
|
350
|
+
)
|
|
351
|
+
except np.linalg.LinAlgError:
|
|
352
|
+
bg_fit = np.mean(z_good)
|
|
353
|
+
residuals = z_good - bg_fit
|
|
354
|
+
else:
|
|
355
|
+
raise ValueError(f"order={self.order} not supported. Use 0, 1, or 2.")
|
|
356
|
+
|
|
357
|
+
# Compute sigma from residuals
|
|
358
|
+
sigma_residuals = np.std(residuals)
|
|
359
|
+
|
|
360
|
+
if sigma_residuals == 0:
|
|
361
|
+
# Perfect fit or constant values, stop
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
# Compute residuals for the current good points using the fit
|
|
365
|
+
# (falls back to the constant when the polynomial fit failed)
|
|
366
|
+
if self.order == 0 or coeffs is None:
|
|
367
|
+
all_residuals = z_annulus[good_mask] - bg_fit
|
|
368
|
+
elif self.order == 1:
|
|
369
|
+
dx_all = x_annulus[good_mask] - xi
|
|
370
|
+
dy_all = y_annulus[good_mask] - yi
|
|
371
|
+
all_residuals = z_annulus[good_mask] - (
|
|
372
|
+
coeffs[0] + coeffs[1] * dx_all + coeffs[2] * dy_all
|
|
373
|
+
)
|
|
374
|
+
elif self.order == 2:
|
|
375
|
+
dx_all = x_annulus[good_mask] - xi
|
|
376
|
+
dy_all = y_annulus[good_mask] - yi
|
|
377
|
+
all_residuals = z_annulus[good_mask] - (
|
|
378
|
+
coeffs[0]
|
|
379
|
+
+ coeffs[1] * dx_all
|
|
380
|
+
+ coeffs[2] * dy_all
|
|
381
|
+
+ coeffs[3] * dx_all**2
|
|
382
|
+
+ coeffs[4] * dy_all**2
|
|
383
|
+
+ coeffs[5] * dx_all * dy_all
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Reject outliers beyond sigma threshold
|
|
387
|
+
outliers = np.abs(all_residuals) > self.sigma * sigma_residuals
|
|
388
|
+
|
|
389
|
+
if not np.any(outliers):
|
|
390
|
+
# No more outliers, converged
|
|
391
|
+
break
|
|
392
|
+
|
|
393
|
+
# Update mask - create new mask relative to original good_mask
|
|
394
|
+
good_indices = np.where(good_mask)[0]
|
|
395
|
+
good_mask[good_indices[outliers]] = False
|
|
396
|
+
|
|
397
|
+
# Final fit with cleaned data
|
|
398
|
+
x_final = x_annulus[good_mask]
|
|
399
|
+
y_final = y_annulus[good_mask]
|
|
400
|
+
z_final = z_annulus[good_mask]
|
|
401
|
+
|
|
402
|
+
if len(z_final) < max(10, (self.order + 1) * (self.order + 2) // 2):
|
|
403
|
+
# Sigma-clipping rejected too many points, use all data
|
|
404
|
+
x_final = x_annulus
|
|
405
|
+
y_final = y_annulus
|
|
406
|
+
z_final = z_annulus
|
|
407
|
+
|
|
408
|
+
# Fit gradient with cleaned data
|
|
409
|
+
if self.order == 0:
|
|
410
|
+
# Constant (mean)
|
|
411
|
+
bg_values[i] = np.mean(z_final)
|
|
412
|
+
|
|
413
|
+
elif self.order == 1:
|
|
414
|
+
# Plane: z = a + b*(x-x0) + c*(y-y0)
|
|
415
|
+
dx = x_final - xi
|
|
416
|
+
dy = y_final - yi
|
|
417
|
+
A = np.column_stack([np.ones_like(x_final), dx, dy])
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
coeffs = np.linalg.lstsq(A, z_final, rcond=None)[0]
|
|
421
|
+
bg_values[i] = coeffs[0] # Value at source position
|
|
422
|
+
except np.linalg.LinAlgError:
|
|
423
|
+
bg_values[i] = np.mean(z_final)
|
|
424
|
+
|
|
425
|
+
elif self.order == 2:
|
|
426
|
+
# Quadratic: z = a + b*dx + c*dy + d*dx^2 + e*dy^2 + f*dx*dy
|
|
427
|
+
dx = x_final - xi
|
|
428
|
+
dy = y_final - yi
|
|
429
|
+
A = np.column_stack([np.ones_like(x_final), dx, dy, dx**2, dy**2, dx * dy])
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
coeffs = np.linalg.lstsq(A, z_final, rcond=None)[0]
|
|
433
|
+
bg_values[i] = coeffs[0] # Value at source position
|
|
434
|
+
except np.linalg.LinAlgError:
|
|
435
|
+
bg_values[i] = np.mean(z_final)
|
|
436
|
+
|
|
437
|
+
else:
|
|
438
|
+
raise ValueError(f"order={self.order} not supported. Use 0, 1, or 2.")
|
|
439
|
+
|
|
440
|
+
return bg_values[0] if scalar_input else bg_values
|
|
441
|
+
|
|
442
|
+
def __repr__(self):
|
|
443
|
+
return (
|
|
444
|
+
f"GradientLocalBackground(inner_radius={self.inner_radius}, "
|
|
445
|
+
f"outer_radius={self.outer_radius}, order={self.order}, "
|
|
446
|
+
f"sigma={self.sigma}, maxiters={self.maxiters})"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def measure_objects_psf(
|
|
451
|
+
obj,
|
|
452
|
+
image,
|
|
453
|
+
psf=None,
|
|
454
|
+
psf_size=None,
|
|
455
|
+
fwhm=None,
|
|
456
|
+
mask=None,
|
|
457
|
+
bg=None,
|
|
458
|
+
err=None,
|
|
459
|
+
gain=None,
|
|
460
|
+
bg_size=64,
|
|
461
|
+
bkgann=None,
|
|
462
|
+
bkg_order=1,
|
|
463
|
+
sn=None,
|
|
464
|
+
fit_shape='circular',
|
|
465
|
+
fit_size=None,
|
|
466
|
+
maxiters=3,
|
|
467
|
+
recentroid=True,
|
|
468
|
+
keep_negative=True,
|
|
469
|
+
get_bg=False,
|
|
470
|
+
use_position_dependent_psf=False,
|
|
471
|
+
group_sources=True,
|
|
472
|
+
grouper_radius=None,
|
|
473
|
+
compute_quality=True,
|
|
474
|
+
verbose=False,
|
|
475
|
+
):
|
|
476
|
+
"""PSF photometry at the positions of already detected objects using photutils.
|
|
477
|
+
|
|
478
|
+
Performs PSF fitting photometry which is more accurate than aperture photometry,
|
|
479
|
+
especially for point sources in crowded fields or when accurate flux measurement
|
|
480
|
+
of PSF wings is important.
|
|
481
|
+
|
|
482
|
+
This function will estimate and subtract the background unless external background
|
|
483
|
+
estimation (`bg`) is provided, and use user-provided noise map (`err`) if requested.
|
|
484
|
+
|
|
485
|
+
If a PSF model is not provided, a simple Gaussian PSF will be constructed based on
|
|
486
|
+
the `fwhm` parameter or estimated from the data.
|
|
487
|
+
|
|
488
|
+
Parameters
|
|
489
|
+
----------
|
|
490
|
+
obj : `~astropy.table.Table`
|
|
491
|
+
Table with initial object detections to be measured. Must have 'x' and
|
|
492
|
+
'y' columns.
|
|
493
|
+
image : `~numpy.ndarray`
|
|
494
|
+
Input image as a 2D NumPy array.
|
|
495
|
+
psf : photutils PSF model, dict, or None, optional
|
|
496
|
+
PSF model to use. Can be a photutils PSF model (e.g.,
|
|
497
|
+
IntegratedGaussianPRF, FittableImageModel), a PSFEx PSF structure from
|
|
498
|
+
:func:`stdpipe.psf.run_psfex`, or None (will create Gaussian PSF based
|
|
499
|
+
on fwhm).
|
|
500
|
+
psf_size : int or None, optional
|
|
501
|
+
Size of the PSF model in pixels. If None, will be estimated from PSF or
|
|
502
|
+
set to 5*fwhm.
|
|
503
|
+
fwhm : float, callable or None, optional
|
|
504
|
+
Full width at half maximum in pixels. Used if PSF model is not provided,
|
|
505
|
+
or to estimate psf_size. If None, will be estimated from obj['fwhm'] if
|
|
506
|
+
available. A position-dependent callable (e.g.
|
|
507
|
+
:class:`stdpipe.photometry.FWHMMap`) is accepted: its scalar summary
|
|
508
|
+
(median) is used for PSF model construction and sizing, while the
|
|
509
|
+
per-source values are used for the PSF quality metrics.
|
|
510
|
+
mask : `~numpy.ndarray` or None, optional
|
|
511
|
+
Image mask as a boolean array (True values will be masked).
|
|
512
|
+
bg : `~numpy.ndarray` or None, optional
|
|
513
|
+
If provided, use this background (same shape as input image) instead of
|
|
514
|
+
automatically computed one.
|
|
515
|
+
err : `~numpy.ndarray` or None, optional
|
|
516
|
+
Image noise map to be used instead of automatically computed one.
|
|
517
|
+
gain : float or None, optional
|
|
518
|
+
Image gain in e-/ADU, used to build image noise model.
|
|
519
|
+
bg_size : int, optional
|
|
520
|
+
Background grid size in pixels.
|
|
521
|
+
bkgann : list of float or None, optional
|
|
522
|
+
Background annulus for local background estimation, [inner_radius,
|
|
523
|
+
outer_radius] in pixels. If None, no local background subtraction is
|
|
524
|
+
performed (relies only on global Background2D subtraction). If set, uses
|
|
525
|
+
gradient-aware local background fitting to handle non-uniform
|
|
526
|
+
backgrounds. Note: radii are NOT scaled by FWHM (unlike
|
|
527
|
+
measure_objects).
|
|
528
|
+
bkg_order : int, optional
|
|
529
|
+
Polynomial order for local background fitting. 0 = constant (mean),
|
|
530
|
+
1 = plane (linear gradient, recommended), 2 = quadratic surface. Only
|
|
531
|
+
used if bkgann is set.
|
|
532
|
+
sn : float or None, optional
|
|
533
|
+
Minimal S/N ratio for the object to be considered good. If set, all
|
|
534
|
+
measurements with magnitude errors exceeding 1/sn will be discarded.
|
|
535
|
+
fit_shape : str, optional
|
|
536
|
+
Accepted for API compatibility ('circular' or 'square'). photutils
|
|
537
|
+
PSFPhotometry always fits a square region of ``fit_size`` pixels, so
|
|
538
|
+
both values currently behave identically.
|
|
539
|
+
fit_size : int or None, optional
|
|
540
|
+
Size of fitting region in pixels. If None, defaults to psf_size.
|
|
541
|
+
maxiters : int, optional
|
|
542
|
+
Maximum number of iterations for PSF fitting.
|
|
543
|
+
recentroid : bool, optional
|
|
544
|
+
If True, allow PSF position to vary during fitting (recommended).
|
|
545
|
+
keep_negative : bool, optional
|
|
546
|
+
If False, measurements with negative fluxes will be discarded.
|
|
547
|
+
get_bg : bool, optional
|
|
548
|
+
If True, the routine will also return estimated background and
|
|
549
|
+
background noise images.
|
|
550
|
+
use_position_dependent_psf : bool, optional
|
|
551
|
+
If True and PSF is a PSFEx model, use polynomial evaluation for
|
|
552
|
+
position-dependent PSF (evaluates PSF at each source position).
|
|
553
|
+
group_sources : bool, optional
|
|
554
|
+
If True, use grouped PSF fitting for overlapping sources. Fits nearby
|
|
555
|
+
sources simultaneously for better accuracy in crowded fields.
|
|
556
|
+
grouper_radius : float or None, optional
|
|
557
|
+
Radius in pixels for grouping nearby sources. If None, defaults to
|
|
558
|
+
``max(2.5*fwhm, fit_size/2)``. Only used if group_sources is True.
|
|
559
|
+
compute_quality : bool, optional
|
|
560
|
+
If True (default), compute crowdsource-style per-source quality
|
|
561
|
+
metrics: ``qf`` (PSF quality factor), ``fracflux`` (fraction of
|
|
562
|
+
stamp flux explained by this source after subtracting the fitted
|
|
563
|
+
models of overlapping neighbours), ``spread_model`` and
|
|
564
|
+
``dspread_model`` (SExtractor-like star/galaxy classifier and its
|
|
565
|
+
uncertainty). Adds a small overhead per source. Not implemented for
|
|
566
|
+
``use_position_dependent_psf=True`` — the columns are then NaN.
|
|
567
|
+
verbose : bool or callable, optional
|
|
568
|
+
Whether to show verbose messages during the run. May be either boolean,
|
|
569
|
+
or a ``print``-like function.
|
|
570
|
+
|
|
571
|
+
Returns
|
|
572
|
+
-------
|
|
573
|
+
result : `~astropy.table.Table` or tuple
|
|
574
|
+
Copy of original table with ``flux``, ``fluxerr``, ``mag``, ``magerr``,
|
|
575
|
+
``x_psf``, ``y_psf`` columns from PSF fitting. Also includes quality of
|
|
576
|
+
fit columns: ``qfit_psf`` (fit quality, 0=good), ``cfit_psf`` (central
|
|
577
|
+
pixel fit quality), ``flags_psf`` (photutils fit flags), ``npix_psf``
|
|
578
|
+
(number of unmasked pixels used in fit), and ``reduced_chi2_psf``
|
|
579
|
+
(reduced chi-squared, available in photutils >= 2.3.0). If `get_bg` is
|
|
580
|
+
True, returns a tuple of (table, background, background_error).
|
|
581
|
+
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
585
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
586
|
+
|
|
587
|
+
if not len(obj):
|
|
588
|
+
log('No objects to measure')
|
|
589
|
+
return obj
|
|
590
|
+
|
|
591
|
+
# Operate on the copy of the table
|
|
592
|
+
obj = obj.copy()
|
|
593
|
+
|
|
594
|
+
from .photometry_measure import (
|
|
595
|
+
_prepare_image_and_mask,
|
|
596
|
+
_extract_valid_positions,
|
|
597
|
+
_compute_magnitudes_and_filter,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
image1, mask0, mask = _prepare_image_and_mask(image, mask)
|
|
601
|
+
|
|
602
|
+
# Background estimation
|
|
603
|
+
if bg is None or err is None or get_bg:
|
|
604
|
+
log('Estimating global background with %dx%d mesh' % (bg_size, bg_size))
|
|
605
|
+
bg_est = photutils.background.Background2D(
|
|
606
|
+
image1, bg_size, mask=mask | mask0, exclude_percentile=90
|
|
607
|
+
)
|
|
608
|
+
bg_est_bg = bg_est.background
|
|
609
|
+
bg_est_rms = bg_est.background_rms
|
|
610
|
+
else:
|
|
611
|
+
bg_est = None
|
|
612
|
+
|
|
613
|
+
if bg is None:
|
|
614
|
+
log(
|
|
615
|
+
'Subtracting global background: median %.1f rms %.2f'
|
|
616
|
+
% (np.median(bg_est_bg), np.std(bg_est_bg))
|
|
617
|
+
)
|
|
618
|
+
image1 -= bg_est_bg
|
|
619
|
+
else:
|
|
620
|
+
log(
|
|
621
|
+
'Subtracting user-provided background: median %.1f rms %.2f'
|
|
622
|
+
% (np.median(bg), np.std(bg))
|
|
623
|
+
)
|
|
624
|
+
image1 -= bg
|
|
625
|
+
|
|
626
|
+
image1[mask0] = 0
|
|
627
|
+
|
|
628
|
+
# Error map
|
|
629
|
+
if err is None:
|
|
630
|
+
log(
|
|
631
|
+
'Using global background noise map: median %.1f rms %.2f + gain %.1f'
|
|
632
|
+
% (
|
|
633
|
+
np.median(bg_est_rms),
|
|
634
|
+
np.std(bg_est_rms),
|
|
635
|
+
gain if gain else np.inf,
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
err = bg_est_rms
|
|
639
|
+
if gain:
|
|
640
|
+
err = calc_total_error(image1, err, gain)
|
|
641
|
+
else:
|
|
642
|
+
log('Using user-provided noise map: median %.1f rms %.2f' % (np.median(err), np.std(err)))
|
|
643
|
+
|
|
644
|
+
# Estimate FWHM if not provided
|
|
645
|
+
if fwhm is None:
|
|
646
|
+
if 'fwhm' in obj.colnames:
|
|
647
|
+
# Use median FWHM from detections
|
|
648
|
+
fwhm_vals = obj['fwhm'][np.isfinite(obj['fwhm'])]
|
|
649
|
+
if len(fwhm_vals) > 0:
|
|
650
|
+
fwhm = np.median(fwhm_vals)
|
|
651
|
+
log('Using median FWHM from detections: %.2f pixels' % fwhm)
|
|
652
|
+
else:
|
|
653
|
+
fwhm = 3.0
|
|
654
|
+
log('No valid FWHM values in detections, using default: %.2f pixels' % fwhm)
|
|
655
|
+
else:
|
|
656
|
+
fwhm = 3.0
|
|
657
|
+
log('FWHM not provided and not in object table, using default: %.2f pixels' % fwhm)
|
|
658
|
+
|
|
659
|
+
# Scalar summary of the FWHM for PSF model construction and stamp
|
|
660
|
+
# sizing; a position-dependent callable (e.g. FWHMMap) stays in ``fwhm``
|
|
661
|
+
# for the per-source quality metrics.
|
|
662
|
+
if _is_callable_fwhm(fwhm):
|
|
663
|
+
fwhm_scalar = _fwhm_median(fwhm, image.shape)
|
|
664
|
+
else:
|
|
665
|
+
fwhm_scalar = fwhm
|
|
666
|
+
|
|
667
|
+
# Create or process PSF model
|
|
668
|
+
psf_is_position_dependent = False # Track if PSF varies with position
|
|
669
|
+
|
|
670
|
+
if psf is None:
|
|
671
|
+
# Create a simple Gaussian PSF
|
|
672
|
+
sigma = fwhm_scalar / (2 * np.sqrt(2 * np.log(2))) # Convert FWHM to sigma
|
|
673
|
+
log(
|
|
674
|
+
'Creating Gaussian PSF model with sigma=%.2f pixels (FWHM=%.2f)'
|
|
675
|
+
% (sigma, fwhm_scalar)
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# Use CircularGaussianSigmaPRF (replaces deprecated IntegratedGaussianPRF)
|
|
679
|
+
psf_model = photutils.psf.CircularGaussianSigmaPRF(sigma=sigma)
|
|
680
|
+
|
|
681
|
+
if psf_size is None:
|
|
682
|
+
psf_size = _odd_int(5 * fwhm_scalar)
|
|
683
|
+
|
|
684
|
+
elif isinstance(psf, dict) and 'data' in psf and 'sampling' in psf:
|
|
685
|
+
# PSFEx-like dict structure (from run_psfex, load_psf, or create_psf_model)
|
|
686
|
+
psf_data = psf['data']
|
|
687
|
+
psf_sampling = psf['sampling']
|
|
688
|
+
psf_degree = psf.get('degree', 0)
|
|
689
|
+
|
|
690
|
+
if use_position_dependent_psf and psf_degree > 0:
|
|
691
|
+
log('Using position-dependent PSFEx PSF model (degree=%d)' % psf_degree)
|
|
692
|
+
# Store the PSFEx model for later use
|
|
693
|
+
# We'll handle position-dependent photometry specially
|
|
694
|
+
psf_model = psf # Keep original PSFEx dict
|
|
695
|
+
psf_is_position_dependent = True
|
|
696
|
+
if psf_size is None:
|
|
697
|
+
psf_size = _compute_native_psf_size(psf['height'], psf_sampling)
|
|
698
|
+
else:
|
|
699
|
+
if psf_degree > 0:
|
|
700
|
+
log(
|
|
701
|
+
'Using spatially varying PSFEx model as constant PSF '
|
|
702
|
+
'(evaluated at the polynomial zero-point; '
|
|
703
|
+
'set use_position_dependent_psf=True for per-source evaluation)'
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
log('Using PSFEx/ePSF PSF model (constant across field)')
|
|
707
|
+
# Evaluate at the polynomial zero-point (~field centre); (0, 0)
|
|
708
|
+
# would extrapolate a varying model to the image corner
|
|
709
|
+
psf_image = psf_module.get_supersampled_psf_stamp(
|
|
710
|
+
psf, x=psf.get('x0', 0), y=psf.get('y0', 0), normalize=True
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
# Handle oversampling if needed
|
|
714
|
+
oversampling = _compute_oversampling(psf_sampling)
|
|
715
|
+
psf_image = _scale_psf_image_for_photutils(psf_image, oversampling)
|
|
716
|
+
psf_model = photutils.psf.ImagePSF(psf_image, oversampling=oversampling)
|
|
717
|
+
psf_is_position_dependent = False
|
|
718
|
+
|
|
719
|
+
if psf_size is None:
|
|
720
|
+
psf_size = _compute_native_psf_size(psf_image.shape[0], psf_sampling)
|
|
721
|
+
|
|
722
|
+
elif isinstance(psf, (photutils.psf.ImagePSF, photutils.psf.FittableImageModel)):
|
|
723
|
+
# Already a photutils PSF model (ImagePSF or legacy FittableImageModel)
|
|
724
|
+
log('Using provided photutils ImagePSF model')
|
|
725
|
+
psf_model = psf
|
|
726
|
+
if psf_size is None:
|
|
727
|
+
psf_size = _odd_int(psf.data.shape[0])
|
|
728
|
+
|
|
729
|
+
elif hasattr(psf, 'fwhm'):
|
|
730
|
+
# Photutils ePSF or similar
|
|
731
|
+
log('Using provided photutils PSF model with FWHM')
|
|
732
|
+
psf_model = psf
|
|
733
|
+
if psf_size is None:
|
|
734
|
+
psf_size = (
|
|
735
|
+
_odd_int(psf.data.shape[0]) if hasattr(psf, 'data') else _odd_int(5 * psf.fwhm)
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
else:
|
|
739
|
+
# Assume it's a photutils PSF model
|
|
740
|
+
log('Using provided PSF model')
|
|
741
|
+
psf_model = psf
|
|
742
|
+
if psf_size is None:
|
|
743
|
+
psf_size = _odd_int(5 * fwhm_scalar)
|
|
744
|
+
|
|
745
|
+
log('Using PSF size: %d pixels' % psf_size)
|
|
746
|
+
|
|
747
|
+
# Fitting region size
|
|
748
|
+
if fit_size is None:
|
|
749
|
+
fit_size = psf_size
|
|
750
|
+
fit_size = _odd_int(fit_size)
|
|
751
|
+
log('Using fitting region size: %d pixels' % fit_size)
|
|
752
|
+
|
|
753
|
+
# Prepare initial positions table
|
|
754
|
+
x_vals, y_vals, valid_pos = _extract_valid_positions(obj)
|
|
755
|
+
init_params = Table()
|
|
756
|
+
init_params['x'] = x_vals
|
|
757
|
+
init_params['y'] = y_vals
|
|
758
|
+
|
|
759
|
+
# Add initial flux guesses if available
|
|
760
|
+
if 'flux' in obj.colnames:
|
|
761
|
+
flux0 = np.ma.filled(np.asarray(obj['flux'], dtype=float), fill_value=np.nan)
|
|
762
|
+
# A non-finite initial flux poisons the fit of the source (and, in
|
|
763
|
+
# grouped mode, of its whole group) — fall back to a finite guess
|
|
764
|
+
bad0 = ~np.isfinite(flux0)
|
|
765
|
+
if np.any(bad0):
|
|
766
|
+
finite0 = flux0[~bad0]
|
|
767
|
+
flux0[bad0] = np.median(finite0) if len(finite0) else 1000.0
|
|
768
|
+
init_params['flux'] = flux0
|
|
769
|
+
else:
|
|
770
|
+
# Estimate initial flux from image at positions
|
|
771
|
+
init_params['flux'] = 1000.0 # Default initial guess
|
|
772
|
+
|
|
773
|
+
if fit_shape not in ['circular', 'square']:
|
|
774
|
+
raise ValueError("fit_shape must be 'circular' or 'square'")
|
|
775
|
+
|
|
776
|
+
# Import fitting class
|
|
777
|
+
from astropy.modeling.fitting import LevMarLSQFitter
|
|
778
|
+
|
|
779
|
+
# Configure grouping if requested
|
|
780
|
+
grouper = None
|
|
781
|
+
if group_sources:
|
|
782
|
+
if grouper_radius is None:
|
|
783
|
+
# Sources interact when their fitting boxes overlap appreciably;
|
|
784
|
+
# ~2.5 FWHM (or half the fit box) covers that while avoiding the
|
|
785
|
+
# huge chained groups a stamp-sized radius produces in dense fields
|
|
786
|
+
grouper_radius = max(2.5 * fwhm_scalar, 0.5 * fit_size)
|
|
787
|
+
log('Using grouped PSF fitting with grouper radius %.1f pixels' % grouper_radius)
|
|
788
|
+
grouper = photutils.psf.SourceGrouper(min_separation=grouper_radius)
|
|
789
|
+
|
|
790
|
+
# Check for invalid positions (from masked columns)
|
|
791
|
+
n_invalid = np.sum(~valid_pos)
|
|
792
|
+
if n_invalid > 0:
|
|
793
|
+
log('Found %d objects with invalid (masked/NaN) positions, will be skipped' % n_invalid)
|
|
794
|
+
|
|
795
|
+
mask_for_fit = mask | mask0
|
|
796
|
+
|
|
797
|
+
xy_bounds = None if recentroid else 1e-6
|
|
798
|
+
|
|
799
|
+
# Perform PSF photometry
|
|
800
|
+
log('Performing PSF photometry on %d objects (%d valid)' % (len(obj), np.sum(valid_pos)))
|
|
801
|
+
log(
|
|
802
|
+
'Settings: %d iterations, recentroid=%s, grouped=%s, position_dependent=%s'
|
|
803
|
+
% (maxiters, recentroid, group_sources, psf_is_position_dependent)
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
# Handle position-dependent PSF separately
|
|
807
|
+
if psf_is_position_dependent:
|
|
808
|
+
log('Performing position-dependent PSF photometry (per-group PSF evaluation)')
|
|
809
|
+
# Initialize output columns
|
|
810
|
+
obj['flux'] = np.nan
|
|
811
|
+
obj['fluxerr'] = np.nan
|
|
812
|
+
obj['x_psf'] = obj['x']
|
|
813
|
+
obj['y_psf'] = obj['y']
|
|
814
|
+
obj['qfit_psf'] = np.nan
|
|
815
|
+
obj['cfit_psf'] = np.nan
|
|
816
|
+
obj['flags_psf'] = 0
|
|
817
|
+
obj['npix_psf'] = 0
|
|
818
|
+
obj['reduced_chi2_psf'] = np.nan
|
|
819
|
+
if compute_quality:
|
|
820
|
+
# Quality metrics are not implemented for the per-source iterative
|
|
821
|
+
# mode (no joint model image to subtract neighbours from) — keep
|
|
822
|
+
# the output schema consistent with the standard path via NaNs.
|
|
823
|
+
log('PSF quality metrics are not computed in position-dependent mode')
|
|
824
|
+
obj['qf'] = np.nan
|
|
825
|
+
obj['fracflux'] = np.nan
|
|
826
|
+
obj['spread_model'] = np.nan
|
|
827
|
+
obj['dspread_model'] = np.nan
|
|
828
|
+
if 'flags' not in obj.keys():
|
|
829
|
+
obj['flags'] = 0
|
|
830
|
+
|
|
831
|
+
# Mark invalid positions (masked/NaN) as failed
|
|
832
|
+
obj['flags'][~valid_pos] |= 0x1000
|
|
833
|
+
|
|
834
|
+
# Get sampling (psf_model is always dict at this point)
|
|
835
|
+
psf_sampling = psf_model['sampling']
|
|
836
|
+
oversampling = _compute_oversampling(psf_sampling)
|
|
837
|
+
|
|
838
|
+
# Set up local background estimator if requested (shared by all groups)
|
|
839
|
+
localbkg_estimator = None
|
|
840
|
+
if bkgann is not None and len(bkgann) == 2:
|
|
841
|
+
localbkg_estimator = GradientLocalBackground(bkgann[0], bkgann[1], order=bkg_order)
|
|
842
|
+
|
|
843
|
+
# Group nearby sources and fit each group jointly with a PSF
|
|
844
|
+
# evaluated once at the group position; fitting sources one at a
|
|
845
|
+
# time would leave neighbour flux unmodelled exactly in the crowded
|
|
846
|
+
# wide-field cases where a varying PSF matters most
|
|
847
|
+
valid_idx = np.where(valid_pos)[0]
|
|
848
|
+
xv = np.asarray(init_params['x'], dtype=float)[valid_idx]
|
|
849
|
+
yv = np.asarray(init_params['y'], dtype=float)[valid_idx]
|
|
850
|
+
fv = np.asarray(init_params['flux'], dtype=float)[valid_idx]
|
|
851
|
+
|
|
852
|
+
if grouper is not None and len(valid_idx) > 1:
|
|
853
|
+
group_ids = np.asarray(grouper(xv, yv))
|
|
854
|
+
else:
|
|
855
|
+
group_ids = np.arange(len(valid_idx))
|
|
856
|
+
|
|
857
|
+
for gid in np.unique(group_ids):
|
|
858
|
+
in_group = group_ids == gid
|
|
859
|
+
sel = valid_idx[in_group]
|
|
860
|
+
|
|
861
|
+
try:
|
|
862
|
+
# Evaluate PSF at the group mean position (the PSF varies
|
|
863
|
+
# smoothly on the scale of a group)
|
|
864
|
+
psf_image = psf_module.get_supersampled_psf_stamp(
|
|
865
|
+
psf_model,
|
|
866
|
+
x=float(np.mean(xv[in_group])),
|
|
867
|
+
y=float(np.mean(yv[in_group])),
|
|
868
|
+
normalize=True,
|
|
869
|
+
)
|
|
870
|
+
psf_image = _scale_psf_image_for_photutils(psf_image, oversampling)
|
|
871
|
+
|
|
872
|
+
# Create photutils PSF model for this group position
|
|
873
|
+
psf_at_pos = photutils.psf.ImagePSF(psf_image, oversampling=oversampling)
|
|
874
|
+
|
|
875
|
+
# Set up photometry for this group
|
|
876
|
+
phot_group = photutils.psf.PSFPhotometry(
|
|
877
|
+
psf_model=psf_at_pos,
|
|
878
|
+
fit_shape=fit_size,
|
|
879
|
+
finder=None,
|
|
880
|
+
grouper=grouper,
|
|
881
|
+
fitter=LevMarLSQFitter(),
|
|
882
|
+
fitter_maxiters=maxiters,
|
|
883
|
+
xy_bounds=xy_bounds,
|
|
884
|
+
aperture_radius=fit_size / 2,
|
|
885
|
+
localbkg_estimator=localbkg_estimator,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
# Measure this group
|
|
889
|
+
init_group = Table()
|
|
890
|
+
init_group['x'] = xv[in_group]
|
|
891
|
+
init_group['y'] = yv[in_group]
|
|
892
|
+
init_group['flux'] = fv[in_group]
|
|
893
|
+
|
|
894
|
+
result_group = phot_group(
|
|
895
|
+
image1, mask=mask_for_fit, error=err, init_params=init_group
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
# Extract results (result rows follow init_params order)
|
|
899
|
+
for row, i in enumerate(sel):
|
|
900
|
+
obj['flux'][i] = result_group['flux_fit'][row]
|
|
901
|
+
obj['fluxerr'][i] = result_group['flux_err'][row]
|
|
902
|
+
obj['x_psf'][i] = result_group['x_fit'][row]
|
|
903
|
+
obj['y_psf'][i] = result_group['y_fit'][row]
|
|
904
|
+
|
|
905
|
+
# Extract quality of fit columns if available
|
|
906
|
+
if 'qfit' in result_group.colnames:
|
|
907
|
+
obj['qfit_psf'][i] = result_group['qfit'][row]
|
|
908
|
+
if 'cfit' in result_group.colnames:
|
|
909
|
+
obj['cfit_psf'][i] = result_group['cfit'][row]
|
|
910
|
+
if 'flags' in result_group.colnames:
|
|
911
|
+
obj['flags_psf'][i] = result_group['flags'][row]
|
|
912
|
+
if 'npixfit' in result_group.colnames:
|
|
913
|
+
obj['npix_psf'][i] = result_group['npixfit'][row]
|
|
914
|
+
if 'reduced_chi2' in result_group.colnames:
|
|
915
|
+
obj['reduced_chi2_psf'][i] = result_group['reduced_chi2'][row]
|
|
916
|
+
|
|
917
|
+
# Flag if fit failed
|
|
918
|
+
if not np.isfinite(obj['flux'][i]):
|
|
919
|
+
obj['flags'][i] |= 0x1000
|
|
920
|
+
# Also flag if fit didn't converge or returned input unchanged
|
|
921
|
+
elif 'flags' in result_group.colnames:
|
|
922
|
+
# Check bit 0 (convergence failure)
|
|
923
|
+
bit0_set = (result_group['flags'][row] & 1) != 0
|
|
924
|
+
|
|
925
|
+
# Check for exact match with input when photutils claims it converged
|
|
926
|
+
# (bit 0 NOT set). This catches cases where photutils returns input
|
|
927
|
+
# unchanged but doesn't set bit 0.
|
|
928
|
+
converged_but_unchanged = (
|
|
929
|
+
(result_group['flags'][row] & 1) == 0
|
|
930
|
+
and obj['flux'][i] == init_group['flux'][row]
|
|
931
|
+
and obj['x_psf'][i] == init_group['x'][row]
|
|
932
|
+
and obj['y_psf'][i] == init_group['y'][row]
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
if bit0_set or converged_but_unchanged:
|
|
936
|
+
log(
|
|
937
|
+
'Warning: PSF fit did not converge or returned unchanged parameters for object %d, setting flux to NaN'
|
|
938
|
+
% i
|
|
939
|
+
)
|
|
940
|
+
obj['flux'][i] = np.nan
|
|
941
|
+
obj['fluxerr'][i] = np.nan
|
|
942
|
+
obj['flags'][i] |= 0x1000
|
|
943
|
+
|
|
944
|
+
# Flag if position moved significantly
|
|
945
|
+
if recentroid:
|
|
946
|
+
if (
|
|
947
|
+
np.sqrt(
|
|
948
|
+
(obj['x_psf'][i] - obj['x'][i]) ** 2
|
|
949
|
+
+ (obj['y_psf'][i] - obj['y'][i]) ** 2
|
|
950
|
+
)
|
|
951
|
+
> 1.0
|
|
952
|
+
):
|
|
953
|
+
obj['flags'][i] |= 0x2000
|
|
954
|
+
|
|
955
|
+
except Exception as e:
|
|
956
|
+
log('PSF photometry failed for group of %d objects: %s' % (len(sel), str(e)))
|
|
957
|
+
for i in sel:
|
|
958
|
+
obj['flux'][i] = np.nan
|
|
959
|
+
obj['fluxerr'][i] = np.nan
|
|
960
|
+
obj['flags'][i] |= 0x1000
|
|
961
|
+
|
|
962
|
+
else:
|
|
963
|
+
# Standard (non-position-dependent) PSF photometry
|
|
964
|
+
# Initialize output columns with NaN (for invalid positions)
|
|
965
|
+
obj['flux'] = np.nan
|
|
966
|
+
obj['fluxerr'] = np.nan
|
|
967
|
+
obj['x_psf'] = np.ma.filled(np.asarray(obj['x']), fill_value=np.nan)
|
|
968
|
+
obj['y_psf'] = np.ma.filled(np.asarray(obj['y']), fill_value=np.nan)
|
|
969
|
+
obj['qfit_psf'] = np.nan
|
|
970
|
+
obj['cfit_psf'] = np.nan
|
|
971
|
+
obj['flags_psf'] = 0
|
|
972
|
+
obj['npix_psf'] = 0
|
|
973
|
+
obj['reduced_chi2_psf'] = np.nan
|
|
974
|
+
if compute_quality:
|
|
975
|
+
obj['qf'] = np.nan
|
|
976
|
+
obj['fracflux'] = np.nan
|
|
977
|
+
obj['spread_model'] = np.nan
|
|
978
|
+
obj['dspread_model'] = np.nan
|
|
979
|
+
if 'flags' not in obj.keys():
|
|
980
|
+
obj['flags'] = 0
|
|
981
|
+
|
|
982
|
+
# Mark invalid positions as failed
|
|
983
|
+
obj['flags'][~valid_pos] |= 0x1000
|
|
984
|
+
|
|
985
|
+
# Only proceed if there are valid positions
|
|
986
|
+
if np.sum(valid_pos) > 0:
|
|
987
|
+
try:
|
|
988
|
+
# Filter init_params to valid positions only
|
|
989
|
+
init_params_valid = init_params[valid_pos]
|
|
990
|
+
|
|
991
|
+
# Set up local background estimator if requested
|
|
992
|
+
localbkg_estimator = None
|
|
993
|
+
if bkgann is not None and len(bkgann) == 2:
|
|
994
|
+
inner_rad = bkgann[0]
|
|
995
|
+
outer_rad = bkgann[1]
|
|
996
|
+
|
|
997
|
+
order_names = {0: 'constant (mean)', 1: 'plane', 2: 'quadratic'}
|
|
998
|
+
order_name = order_names.get(bkg_order, f'order-{bkg_order}')
|
|
999
|
+
log(
|
|
1000
|
+
'Using local background annulus %.1f-%.1f pixels with %s fitting'
|
|
1001
|
+
% (inner_rad, outer_rad, order_name)
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
# Create gradient-aware local background estimator
|
|
1005
|
+
localbkg_estimator = GradientLocalBackground(
|
|
1006
|
+
inner_rad, outer_rad, order=bkg_order
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
# Set up photometry object
|
|
1010
|
+
phot_obj = photutils.psf.PSFPhotometry(
|
|
1011
|
+
psf_model=psf_model,
|
|
1012
|
+
fit_shape=fit_size,
|
|
1013
|
+
finder=None, # We already have positions
|
|
1014
|
+
grouper=grouper, # Group nearby sources if requested
|
|
1015
|
+
fitter=LevMarLSQFitter(), # Levenberg-Marquardt fitter from astropy
|
|
1016
|
+
fitter_maxiters=maxiters,
|
|
1017
|
+
xy_bounds=xy_bounds,
|
|
1018
|
+
aperture_radius=fit_size / 2,
|
|
1019
|
+
localbkg_estimator=localbkg_estimator,
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
# Do the photometry - photutils 2.x API
|
|
1023
|
+
result = phot_obj(
|
|
1024
|
+
image1, mask=mask_for_fit, error=err, init_params=init_params_valid
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
# Map results back to full array
|
|
1028
|
+
obj['flux'][valid_pos] = result['flux_fit']
|
|
1029
|
+
obj['fluxerr'][valid_pos] = result['flux_err']
|
|
1030
|
+
obj['x_psf'][valid_pos] = result['x_fit']
|
|
1031
|
+
obj['y_psf'][valid_pos] = result['y_fit']
|
|
1032
|
+
|
|
1033
|
+
# Extract quality of fit columns if available
|
|
1034
|
+
if 'qfit' in result.colnames:
|
|
1035
|
+
obj['qfit_psf'][valid_pos] = result['qfit']
|
|
1036
|
+
if 'cfit' in result.colnames:
|
|
1037
|
+
obj['cfit_psf'][valid_pos] = result['cfit']
|
|
1038
|
+
if 'flags' in result.colnames:
|
|
1039
|
+
obj['flags_psf'][valid_pos] = result['flags']
|
|
1040
|
+
if 'npixfit' in result.colnames:
|
|
1041
|
+
obj['npix_psf'][valid_pos] = result['npixfit']
|
|
1042
|
+
if 'reduced_chi2' in result.colnames:
|
|
1043
|
+
# Available in photutils >= 2.3.0
|
|
1044
|
+
obj['reduced_chi2_psf'][valid_pos] = result['reduced_chi2']
|
|
1045
|
+
|
|
1046
|
+
# Flag objects where fit failed (NaN values)
|
|
1047
|
+
bad_idx = valid_pos & ~np.isfinite(obj['flux'])
|
|
1048
|
+
obj['flags'][bad_idx] |= 0x1000 # PSF fit failed
|
|
1049
|
+
|
|
1050
|
+
# Flag objects where position moved significantly (>1 pixel)
|
|
1051
|
+
if recentroid:
|
|
1052
|
+
moved_idx = valid_pos & (
|
|
1053
|
+
np.sqrt(
|
|
1054
|
+
(obj['x_psf'] - init_params['x']) ** 2
|
|
1055
|
+
+ (obj['y_psf'] - init_params['y']) ** 2
|
|
1056
|
+
)
|
|
1057
|
+
> 1.0
|
|
1058
|
+
)
|
|
1059
|
+
obj['flags'][moved_idx] |= 0x2000 # Large centroid shift
|
|
1060
|
+
|
|
1061
|
+
if compute_quality:
|
|
1062
|
+
quality = _compute_psf_quality_columns(
|
|
1063
|
+
phot_obj,
|
|
1064
|
+
psf_model,
|
|
1065
|
+
image1,
|
|
1066
|
+
err,
|
|
1067
|
+
mask_for_fit,
|
|
1068
|
+
np.asarray(obj['x_psf'])[valid_pos],
|
|
1069
|
+
np.asarray(obj['y_psf'])[valid_pos],
|
|
1070
|
+
np.asarray(obj['flux'])[valid_pos],
|
|
1071
|
+
fit_size,
|
|
1072
|
+
fwhm,
|
|
1073
|
+
log,
|
|
1074
|
+
)
|
|
1075
|
+
if quality is not None:
|
|
1076
|
+
obj['qf'][valid_pos] = quality['qf']
|
|
1077
|
+
obj['fracflux'][valid_pos] = quality['fracflux']
|
|
1078
|
+
obj['spread_model'][valid_pos] = quality['spread_model']
|
|
1079
|
+
obj['dspread_model'][valid_pos] = quality['dspread_model']
|
|
1080
|
+
|
|
1081
|
+
# Flag fits that didn't converge (photutils returns initial guess unchanged)
|
|
1082
|
+
# Check for flags_psf bit 0 (convergence failure) or exact match with input
|
|
1083
|
+
if 'flags_psf' in obj.colnames and 'flux' in init_params.colnames:
|
|
1084
|
+
# Photutils flags: bit 0 = fit did not converge
|
|
1085
|
+
# When fit doesn't converge, photutils returns initial parameters unchanged
|
|
1086
|
+
unconverged = valid_pos & ((obj['flags_psf'] & 1) != 0)
|
|
1087
|
+
|
|
1088
|
+
# Also check for exact byte-level match between input and output when photutils
|
|
1089
|
+
# claims the fit converged (bit 0 NOT set). This catches cases where photutils
|
|
1090
|
+
# returns input unchanged but doesn't set bit 0.
|
|
1091
|
+
converged_but_unchanged = (
|
|
1092
|
+
valid_pos
|
|
1093
|
+
& ((obj['flags_psf'] & 1) == 0)
|
|
1094
|
+
& (obj['flux'] == init_params['flux'])
|
|
1095
|
+
& (obj['x_psf'] == init_params['x'])
|
|
1096
|
+
& (obj['y_psf'] == init_params['y'])
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
# Combine both conditions
|
|
1100
|
+
failed = unconverged | converged_but_unchanged
|
|
1101
|
+
|
|
1102
|
+
if np.sum(failed) > 0:
|
|
1103
|
+
log(
|
|
1104
|
+
'Warning: %d PSF fits failed or returned unchanged parameters, setting flux to NaN'
|
|
1105
|
+
% np.sum(failed)
|
|
1106
|
+
)
|
|
1107
|
+
obj['flux'][failed] = np.nan
|
|
1108
|
+
obj['fluxerr'][failed] = np.nan
|
|
1109
|
+
obj['flags'][failed] |= 0x1000 # PSF fit failed
|
|
1110
|
+
|
|
1111
|
+
except Exception as e:
|
|
1112
|
+
log('PSF photometry failed: %s' % str(e))
|
|
1113
|
+
log('Falling back to NaN values')
|
|
1114
|
+
obj['flags'][valid_pos] |= 0x1000
|
|
1115
|
+
|
|
1116
|
+
obj = _compute_magnitudes_and_filter(obj, sn, keep_negative, log)
|
|
1117
|
+
|
|
1118
|
+
log('PSF photometry complete: %d objects measured' % len(obj))
|
|
1119
|
+
|
|
1120
|
+
if get_bg:
|
|
1121
|
+
# Return the background that was actually subtracted
|
|
1122
|
+
return obj, (bg if bg is not None else bg_est_bg), err
|
|
1123
|
+
else:
|
|
1124
|
+
return obj
|