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,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))