stdpipe 0.4.0__py3-none-any.whl

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