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.
@@ -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