stdpipe 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
stdpipe/psf.py
ADDED
|
@@ -0,0 +1,1542 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module for working with point-spread function (PSF) models
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os, shutil, tempfile, shlex
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from astropy.io import fits
|
|
9
|
+
from astropy.table import Table
|
|
10
|
+
from astropy.nddata import NDData
|
|
11
|
+
|
|
12
|
+
from scipy import ndimage
|
|
13
|
+
|
|
14
|
+
import photutils.psf
|
|
15
|
+
|
|
16
|
+
from . import photometry
|
|
17
|
+
from . import utils
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_psfex(
|
|
21
|
+
image,
|
|
22
|
+
mask=None,
|
|
23
|
+
thresh=2.0,
|
|
24
|
+
aper=None,
|
|
25
|
+
r0=0.0,
|
|
26
|
+
gain=1,
|
|
27
|
+
minarea=5,
|
|
28
|
+
vignet_size=None,
|
|
29
|
+
psf_size=None,
|
|
30
|
+
order=0,
|
|
31
|
+
sex_extra=None,
|
|
32
|
+
checkimages=None,
|
|
33
|
+
extra=None,
|
|
34
|
+
psffile=None,
|
|
35
|
+
get_obj=False,
|
|
36
|
+
_workdir=None,
|
|
37
|
+
_tmpdir=None,
|
|
38
|
+
_exe=None,
|
|
39
|
+
_sex_exe=None,
|
|
40
|
+
verbose=False,
|
|
41
|
+
):
|
|
42
|
+
"""Wrapper around PSFEx to help extracting PSF models from images.
|
|
43
|
+
|
|
44
|
+
For the details of PSFEx operation we suggest to consult its documentation at https://psfex.readthedocs.io
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
image : numpy.ndarray
|
|
49
|
+
Input image as a NumPy array.
|
|
50
|
+
mask : numpy.ndarray, optional
|
|
51
|
+
Image mask as a boolean array (True values will be masked).
|
|
52
|
+
thresh : float, optional
|
|
53
|
+
Detection threshold in sigmas above local background, for running initial SExtractor object detection.
|
|
54
|
+
aper : float, optional
|
|
55
|
+
Circular aperture radius in pixels, to be used for PSF normalization. Should contain most of
|
|
56
|
+
object flux. If not specified, will be estimated as twice the FWHM.
|
|
57
|
+
r0 : float, optional
|
|
58
|
+
Smoothing kernel size (sigma) to be used for improving object detection in initial SExtractor call.
|
|
59
|
+
gain : float, optional
|
|
60
|
+
Image gain.
|
|
61
|
+
minarea : int, optional
|
|
62
|
+
Minimal number of pixels in the object to be considered a detection (``DETECT_MINAREA`` parameter
|
|
63
|
+
of SExtractor).
|
|
64
|
+
vignet_size : int, optional
|
|
65
|
+
The size of *postage stamps* to be used for PSF model creation.
|
|
66
|
+
psf_size : int, optional
|
|
67
|
+
The size of the supersampled PSF model.
|
|
68
|
+
order : int, optional
|
|
69
|
+
Spatial order of PSF model variance.
|
|
70
|
+
sex_extra : dict, optional
|
|
71
|
+
Dictionary of additional options to be passed to SExtractor for initial object detection
|
|
72
|
+
(``extra`` parameter of :func:`stdpipe.photometry.get_objects_sextractor`).
|
|
73
|
+
checkimages : list, optional
|
|
74
|
+
List of PSFEx checkimages to return along with PSF model.
|
|
75
|
+
extra : dict, optional
|
|
76
|
+
Dictionary of extra configuration parameters to be passed to PSFEx call, with keys as
|
|
77
|
+
parameter names. See :code:`psfex -dd` for the full list.
|
|
78
|
+
psffile : str, optional
|
|
79
|
+
If specified, PSF model file will also be stored under this file name, so that it may e.g.
|
|
80
|
+
be re-used by SExtractor later.
|
|
81
|
+
get_obj : bool, optional
|
|
82
|
+
If set, also return the table with SExtractor detected objects.
|
|
83
|
+
_workdir : str, optional
|
|
84
|
+
If specified, all temporary files will be created in this directory, and will be kept intact
|
|
85
|
+
after running SExtractor and PSFEx. May be used for debugging exact inputs and outputs of
|
|
86
|
+
the executable.
|
|
87
|
+
_tmpdir : str, optional
|
|
88
|
+
If specified, all temporary files will be created in a dedicated directory (that will be
|
|
89
|
+
deleted after running the executable) inside this path.
|
|
90
|
+
_exe : str, optional
|
|
91
|
+
Full path to PSFEx executable. If not provided, the code tries to locate it automatically
|
|
92
|
+
in your :envvar:`PATH`.
|
|
93
|
+
_sex_exe : str, optional
|
|
94
|
+
Full path to SExtractor executable. If not provided, the code tries to locate it automatically
|
|
95
|
+
in your :envvar:`PATH`.
|
|
96
|
+
verbose : bool or callable, optional
|
|
97
|
+
Whether to show verbose messages during the run of the function or not.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
dict
|
|
102
|
+
PSF structure corresponding to the built PSFEx model.
|
|
103
|
+
|
|
104
|
+
The structure has at least the following fields:
|
|
105
|
+
|
|
106
|
+
- ``width``, ``height`` - dimensions of supersampled PSF stamp
|
|
107
|
+
- ``fwhm`` - mean full width at half maximum (FWHM) of the images used for building the PSF model
|
|
108
|
+
- ``sampling`` - conversion factor between PSF stamp (supersampled) pixel size, and original image
|
|
109
|
+
one (less than unity when supersampled resolution is finer than original image one)
|
|
110
|
+
- ``ncoeffs`` - number of coefficients pixel polynomials have
|
|
111
|
+
- ``degree`` - polynomial degree of a spatial variance of PSF model
|
|
112
|
+
- ``data`` - the data containing per-pixel polynomial coefficients for PSF model
|
|
113
|
+
- ``header`` - original FITS header of PSF model file, if :code:`get_header=True` parameter was set
|
|
114
|
+
|
|
115
|
+
This structure corresponds to the contents of original PSFEx generated output file that
|
|
116
|
+
is documented at https://psfex.readthedocs.io/en/latest/Appendices.html#psf-file-format-description
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
121
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
122
|
+
|
|
123
|
+
# Find the binary
|
|
124
|
+
binname = None
|
|
125
|
+
|
|
126
|
+
if _exe is not None:
|
|
127
|
+
# Check user-provided binary path, and fail if not found
|
|
128
|
+
if os.path.isfile(_exe):
|
|
129
|
+
binname = _exe
|
|
130
|
+
else:
|
|
131
|
+
# Find PSFEx binary in common paths
|
|
132
|
+
for exe in ['psfex']:
|
|
133
|
+
binname = shutil.which(exe)
|
|
134
|
+
if binname is not None:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if binname is None:
|
|
138
|
+
log("Can't find PSFEx binary")
|
|
139
|
+
return None
|
|
140
|
+
# else:
|
|
141
|
+
# log("Using PSFEx binary at", binname)
|
|
142
|
+
|
|
143
|
+
workdir = _workdir if _workdir is not None else tempfile.mkdtemp(prefix='psfex', dir=_tmpdir)
|
|
144
|
+
psf = None
|
|
145
|
+
|
|
146
|
+
# Estimate image FWHM if aperture radius is not set
|
|
147
|
+
if sex_extra is None:
|
|
148
|
+
sex_extra = {}
|
|
149
|
+
if checkimages is None:
|
|
150
|
+
checkimages = []
|
|
151
|
+
if extra is None:
|
|
152
|
+
extra = {}
|
|
153
|
+
|
|
154
|
+
if not aper:
|
|
155
|
+
log('Aperture size not specified, will estimate it from image FWHM')
|
|
156
|
+
obj = photometry.get_objects_sextractor(
|
|
157
|
+
image,
|
|
158
|
+
mask=mask,
|
|
159
|
+
thresh=thresh,
|
|
160
|
+
aper=3.0,
|
|
161
|
+
r0=r0,
|
|
162
|
+
gain=gain,
|
|
163
|
+
minarea=minarea,
|
|
164
|
+
_workdir=workdir,
|
|
165
|
+
_tmpdir=_tmpdir,
|
|
166
|
+
_exe=_sex_exe,
|
|
167
|
+
verbose=verbose,
|
|
168
|
+
extra=sex_extra,
|
|
169
|
+
)
|
|
170
|
+
fwhm_vals = obj['fwhm'][obj['flags'] == 0]
|
|
171
|
+
if len(fwhm_vals) > 0:
|
|
172
|
+
fwhm = np.median(fwhm_vals)
|
|
173
|
+
else:
|
|
174
|
+
fwhm = np.nan
|
|
175
|
+
if not np.isfinite(fwhm) or fwhm <= 0:
|
|
176
|
+
fwhm = 3.0
|
|
177
|
+
log('FWHM estimate failed, using default %.1f pixels' % fwhm)
|
|
178
|
+
aper = 2.0 * fwhm
|
|
179
|
+
log('FWHM = %.1f pixels, will use aperture radius %.1f pixels' % (fwhm, aper))
|
|
180
|
+
|
|
181
|
+
if vignet_size is None:
|
|
182
|
+
vignet_size = int(np.ceil(6 * aper)) + 1
|
|
183
|
+
else:
|
|
184
|
+
vignet_size = int(np.round(vignet_size))
|
|
185
|
+
if vignet_size % 2 == 0:
|
|
186
|
+
vignet_size += 1
|
|
187
|
+
log('Extracting PSF using vignette size %d x %d pixels' % (vignet_size, vignet_size))
|
|
188
|
+
|
|
189
|
+
# Run SExtractor on input image in current workdir so that the LDAC catalogue will be in out.cat there
|
|
190
|
+
obj = photometry.get_objects_sextractor(
|
|
191
|
+
image,
|
|
192
|
+
mask=mask,
|
|
193
|
+
thresh=thresh,
|
|
194
|
+
aper=aper,
|
|
195
|
+
r0=r0,
|
|
196
|
+
gain=gain,
|
|
197
|
+
minarea=minarea,
|
|
198
|
+
_workdir=workdir,
|
|
199
|
+
_tmpdir=_tmpdir,
|
|
200
|
+
_exe=_sex_exe,
|
|
201
|
+
verbose=verbose,
|
|
202
|
+
extra_params=[
|
|
203
|
+
'SNR_WIN',
|
|
204
|
+
'ELONGATION',
|
|
205
|
+
'VIGNET(%d,%d)' % (vignet_size, vignet_size),
|
|
206
|
+
],
|
|
207
|
+
extra=sex_extra,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
catname = os.path.join(workdir, 'out.cat')
|
|
211
|
+
psfname = os.path.join(workdir, 'out.psf')
|
|
212
|
+
|
|
213
|
+
# Dummy config filename, to prevent loading from current dir
|
|
214
|
+
confname = os.path.join(workdir, 'empty.conf')
|
|
215
|
+
utils.file_write(confname)
|
|
216
|
+
|
|
217
|
+
opts = {
|
|
218
|
+
'c': confname,
|
|
219
|
+
'VERBOSE_TYPE': 'QUIET',
|
|
220
|
+
'CHECKPLOT_TYPE': 'NONE',
|
|
221
|
+
'CHECKIMAGE_TYPE': 'NONE',
|
|
222
|
+
'PSFVAR_DEGREES': order,
|
|
223
|
+
'WRITE_XML': 'N',
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
checknames = [os.path.join(workdir, _.replace('-', 'M_') + '.fits') for _ in checkimages]
|
|
227
|
+
if checkimages:
|
|
228
|
+
opts['CHECKIMAGE_TYPE'] = ','.join(checkimages)
|
|
229
|
+
opts['CHECKIMAGE_NAME'] = ','.join(checknames)
|
|
230
|
+
|
|
231
|
+
opts.update(extra)
|
|
232
|
+
|
|
233
|
+
if psf_size is not None:
|
|
234
|
+
opts['PSF_SIZE'] = [psf_size, psf_size]
|
|
235
|
+
|
|
236
|
+
# Build the command line
|
|
237
|
+
cmd = binname + ' ' + shlex.quote(catname) + ' ' + utils.format_astromatic_opts(opts)
|
|
238
|
+
if not verbose:
|
|
239
|
+
cmd += ' > /dev/null 2>/dev/null'
|
|
240
|
+
log('Will run PSFEx like that:')
|
|
241
|
+
log(cmd)
|
|
242
|
+
|
|
243
|
+
# Run the command!
|
|
244
|
+
|
|
245
|
+
res = os.system(cmd)
|
|
246
|
+
|
|
247
|
+
if res == 0 and os.path.exists(psfname):
|
|
248
|
+
log('PSFEx run succeeded')
|
|
249
|
+
|
|
250
|
+
psf = load_psf(psfname, verbose=verbose)
|
|
251
|
+
|
|
252
|
+
# Check whether the PSF model extends beyond the vignet coverage.
|
|
253
|
+
# PSFEx uses Lanczos interpolation with renormalization at boundaries
|
|
254
|
+
# when resampling vignets into the PSF grid. When the PSF model is
|
|
255
|
+
# larger than the vignet (in image pixels), the boundary pixels get
|
|
256
|
+
# spurious non-zero values from the renormalized partial kernel,
|
|
257
|
+
# which biases the PSF wings and corrupts flux measurements.
|
|
258
|
+
psf_extent = psf['width'] * psf['sampling']
|
|
259
|
+
if psf_extent > vignet_size:
|
|
260
|
+
import warnings
|
|
261
|
+
|
|
262
|
+
warnings.warn(
|
|
263
|
+
"PSF model extent (%.0f x %.0f pixels = psf_size %d x sampling %.3f) "
|
|
264
|
+
"exceeds vignet size (%d pixels). This causes interpolation artifacts "
|
|
265
|
+
"in the PSF wings that bias flux measurements. "
|
|
266
|
+
"Either increase vignet_size to >= %.0f or decrease psf_size to <= %d."
|
|
267
|
+
% (
|
|
268
|
+
psf_extent,
|
|
269
|
+
psf_extent,
|
|
270
|
+
psf['width'],
|
|
271
|
+
psf['sampling'],
|
|
272
|
+
vignet_size,
|
|
273
|
+
psf_extent,
|
|
274
|
+
int(vignet_size / psf['sampling']),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if psffile is not None:
|
|
279
|
+
shutil.copyfile(psfname, psffile)
|
|
280
|
+
log("PSF model stored to", psffile)
|
|
281
|
+
|
|
282
|
+
else:
|
|
283
|
+
log("Error", res, "running PSFEx")
|
|
284
|
+
|
|
285
|
+
result = psf
|
|
286
|
+
|
|
287
|
+
if checkimages:
|
|
288
|
+
result = [result]
|
|
289
|
+
|
|
290
|
+
for name in checknames:
|
|
291
|
+
checkname = os.path.splitext(name)[0] + '_out.fits'
|
|
292
|
+
result.append(fits.getdata(checkname))
|
|
293
|
+
|
|
294
|
+
if get_obj:
|
|
295
|
+
if type(result) != list:
|
|
296
|
+
result = [result]
|
|
297
|
+
|
|
298
|
+
result.append(obj)
|
|
299
|
+
|
|
300
|
+
if _workdir is None:
|
|
301
|
+
shutil.rmtree(workdir)
|
|
302
|
+
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load_psf(filename, get_header=False, verbose=False):
|
|
307
|
+
"""Load PSF model from PSFEx file
|
|
308
|
+
|
|
309
|
+
The structure may be useful for inspection of PSF model with :func:`stdpipe.psf.get_supersampled_psf_stamp` and :func:`stdpipe.psf.get_psf_stamp`, as well as for injection of PSF instances (fake objects) into the image with :func:`stdpipe.psf.place_psf_stamp`.
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
filename : str
|
|
314
|
+
Name of a file containing PSF model built by PSFEx.
|
|
315
|
+
get_header : bool, optional
|
|
316
|
+
Whether to return the original FITS header of PSF model file or not. If set,
|
|
317
|
+
the header will be stored in the ``header`` field of the returned structure.
|
|
318
|
+
verbose : bool or callable, optional
|
|
319
|
+
Whether to show verbose messages during the run of the function or not.
|
|
320
|
+
|
|
321
|
+
Returns
|
|
322
|
+
-------
|
|
323
|
+
dict
|
|
324
|
+
PSF structure in the same format as returned from :func:`stdpipe.psf.run_psfex`.
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
329
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
330
|
+
|
|
331
|
+
log('Loading PSF model from %s' % filename)
|
|
332
|
+
|
|
333
|
+
data = fits.getdata(filename, 1)
|
|
334
|
+
header = fits.getheader(filename, 1)
|
|
335
|
+
|
|
336
|
+
psf = {
|
|
337
|
+
'width': header.get('PSFAXIS1'),
|
|
338
|
+
'height': header.get('PSFAXIS2'),
|
|
339
|
+
'ncoeffs': header.get('PSFAXIS3'),
|
|
340
|
+
'fwhm': header.get('PSF_FWHM'),
|
|
341
|
+
'sampling': header.get('PSF_SAMP'),
|
|
342
|
+
'degree': header.get('POLDEG1', 0),
|
|
343
|
+
'x0': header.get('POLZERO1', 0),
|
|
344
|
+
'sx': header.get('POLSCAL1', 1),
|
|
345
|
+
'y0': header.get('POLZERO2', 0),
|
|
346
|
+
'sy': header.get('POLSCAL2', 1),
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if get_header:
|
|
350
|
+
psf['header'] = header
|
|
351
|
+
|
|
352
|
+
psf['data'] = data[0][0]
|
|
353
|
+
|
|
354
|
+
log(
|
|
355
|
+
'PSF model %d x %d pixels, FWHM %.1f pixels, sampling %.2f, degree %d'
|
|
356
|
+
% (psf['width'], psf['height'], psf['fwhm'], psf['sampling'], psf['degree'])
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return psf
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def bilinear_interpolate(im, x, y):
|
|
363
|
+
"""
|
|
364
|
+
Quick and dirty bilinear interpolation
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
x = np.asarray(x)
|
|
368
|
+
y = np.asarray(y)
|
|
369
|
+
|
|
370
|
+
x0 = np.floor(x).astype(int)
|
|
371
|
+
x1 = x0 + 1
|
|
372
|
+
y0 = np.floor(y).astype(int)
|
|
373
|
+
y1 = y0 + 1
|
|
374
|
+
|
|
375
|
+
x0 = np.clip(x0, 0, im.shape[1] - 1)
|
|
376
|
+
x1 = np.clip(x1, 0, im.shape[1] - 1)
|
|
377
|
+
y0 = np.clip(y0, 0, im.shape[0] - 1)
|
|
378
|
+
y1 = np.clip(y1, 0, im.shape[0] - 1)
|
|
379
|
+
|
|
380
|
+
Ia = im[y0, x0]
|
|
381
|
+
Ib = im[y1, x0]
|
|
382
|
+
Ic = im[y0, x1]
|
|
383
|
+
Id = im[y1, x1]
|
|
384
|
+
|
|
385
|
+
wa = (x1 - x) * (y1 - y)
|
|
386
|
+
wb = (x1 - x) * (y - y0)
|
|
387
|
+
wc = (x - x0) * (y1 - y)
|
|
388
|
+
wd = (x - x0) * (y - y0)
|
|
389
|
+
|
|
390
|
+
return wa * Ia + wb * Ib + wc * Ic + wd * Id
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def get_supersampled_psf_stamp(psf, x=0, y=0, normalize=True):
|
|
394
|
+
"""Returns supersampled PSF model for a given position inside the image.
|
|
395
|
+
|
|
396
|
+
The returned stamp corresponds to PSF model evaluated at a given position inside the image,
|
|
397
|
+
with its center always in the center of central stamp pixel.
|
|
398
|
+
Every *supersampled* pixel of the stamp corresponds to :code:`psf['sampling']` pixels of the original image.
|
|
399
|
+
|
|
400
|
+
Parameters
|
|
401
|
+
----------
|
|
402
|
+
psf : dict
|
|
403
|
+
Input PSF structure as returned by :func:`stdpipe.psf.run_psfex` or
|
|
404
|
+
:func:`stdpipe.psf.load_psf`.
|
|
405
|
+
x : float, optional
|
|
406
|
+
``x`` coordinate of the position inside the original image to evaluate the PSF model.
|
|
407
|
+
y : float, optional
|
|
408
|
+
``y`` coordinate of the position inside the original image to evaluate the PSF model.
|
|
409
|
+
normalize : bool, optional
|
|
410
|
+
Whether to normalize the stamp to have flux exactly equal to unity or not.
|
|
411
|
+
|
|
412
|
+
Returns
|
|
413
|
+
-------
|
|
414
|
+
numpy.ndarray
|
|
415
|
+
Stamp of the PSF model evaluated at the given position inside the image.
|
|
416
|
+
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
dx = 1.0 * (x - psf['x0']) / psf['sx']
|
|
420
|
+
dy = 1.0 * (y - psf['y0']) / psf['sy']
|
|
421
|
+
|
|
422
|
+
stamp = np.zeros(psf['data'].shape[1:], dtype=np.double)
|
|
423
|
+
i = 0
|
|
424
|
+
|
|
425
|
+
for i2 in range(0, psf['degree'] + 1):
|
|
426
|
+
for i1 in range(0, psf['degree'] + 1 - i2):
|
|
427
|
+
stamp += psf['data'][i] * dx**i1 * dy**i2
|
|
428
|
+
i += 1
|
|
429
|
+
|
|
430
|
+
if normalize:
|
|
431
|
+
total = np.sum(stamp)
|
|
432
|
+
if np.isfinite(total) and total > 0:
|
|
433
|
+
stamp /= total
|
|
434
|
+
|
|
435
|
+
return stamp
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def get_psf_stamp(psf, x=0, y=0, dx=None, dy=None, normalize=True):
|
|
439
|
+
"""Returns PSF stamp in original image pixel space with sub-pixel shift applied.
|
|
440
|
+
|
|
441
|
+
The PSF model is evaluated at the requested position inside the original image,
|
|
442
|
+
and then downscaled from supersampled pixels of the PSF model to original image pixels,
|
|
443
|
+
and then adjusted to accommodate for requested :code:`(dx, dy)` sub-pixel shift.
|
|
444
|
+
|
|
445
|
+
Stamp is odd-sized, with PSF center at::
|
|
446
|
+
|
|
447
|
+
x0 = floor(width/2) + dx
|
|
448
|
+
y0 = floor(height/2) + dy
|
|
449
|
+
|
|
450
|
+
If :code:`dx=None` or :code:`dy=None`, they are computed directly from the
|
|
451
|
+
floating point parts of the position `x` and `y` arguments::
|
|
452
|
+
|
|
453
|
+
dx = x - round(x)
|
|
454
|
+
dy = y - round(y)
|
|
455
|
+
|
|
456
|
+
The stamp should directly represent stellar shape at a given position (including sub-pixel
|
|
457
|
+
center shift) inside the image.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
psf : dict
|
|
462
|
+
Input PSF structure as returned by :func:`stdpipe.psf.run_psfex` or
|
|
463
|
+
:func:`stdpipe.psf.load_psf`.
|
|
464
|
+
x : float, optional
|
|
465
|
+
``x`` coordinate of the position inside the original image to evaluate the PSF model.
|
|
466
|
+
y : float, optional
|
|
467
|
+
``y`` coordinate of the position inside the original image to evaluate the PSF model.
|
|
468
|
+
dx : float, optional
|
|
469
|
+
Sub-pixel adjustment of PSF position in image space, ``x`` direction.
|
|
470
|
+
dy : float, optional
|
|
471
|
+
Sub-pixel adjustment of PSF position in image space, ``y`` direction.
|
|
472
|
+
normalize : bool, optional
|
|
473
|
+
Whether to normalize the stamp to have flux exactly equal to unity or not.
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
numpy.ndarray
|
|
478
|
+
Stamp of the PSF model evaluated at the given position inside the image, in original
|
|
479
|
+
image pixels.
|
|
480
|
+
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
if dx is None:
|
|
484
|
+
dx = x - np.round(x)
|
|
485
|
+
if dy is None:
|
|
486
|
+
dy = y - np.round(y)
|
|
487
|
+
|
|
488
|
+
supersampled = get_supersampled_psf_stamp(psf, x, y, normalize=normalize)
|
|
489
|
+
|
|
490
|
+
# Oversampling factor
|
|
491
|
+
N = int(round(1.0 / psf['sampling']))
|
|
492
|
+
|
|
493
|
+
if (
|
|
494
|
+
N > 1
|
|
495
|
+
# Block-summing N x N subpixels assumes each subpixel is exactly 1/N
|
|
496
|
+
# image pixels; a non-integer 1/sampling (e.g. PSFEx PSF_SAMP=0.53)
|
|
497
|
+
# would silently rescale the PSF by sampling*N
|
|
498
|
+
and np.isclose(N * psf['sampling'], 1.0, rtol=1e-3, atol=0)
|
|
499
|
+
and supersampled.shape[0] % N == 0
|
|
500
|
+
and supersampled.shape[1] % N == 0
|
|
501
|
+
):
|
|
502
|
+
# Flux-conserving downsampling: shift at oversampled resolution, then sum N×N blocks.
|
|
503
|
+
# The oversampled data stores pixel-integrated flux per subpixel, so the correct
|
|
504
|
+
# way to get image-pixel flux is to sum the subpixels within each image pixel.
|
|
505
|
+
|
|
506
|
+
# Apply sub-pixel shift at oversampled resolution via cubic interpolation.
|
|
507
|
+
# At the oversampled resolution the PSF is well-sampled, so cubic interpolation
|
|
508
|
+
# accurately reconstructs the continuous PSF for sub-pixel shifts.
|
|
509
|
+
shift_x_os = dx / psf['sampling']
|
|
510
|
+
shift_y_os = dy / psf['sampling']
|
|
511
|
+
|
|
512
|
+
if shift_x_os != 0 or shift_y_os != 0:
|
|
513
|
+
shifted = ndimage.shift(
|
|
514
|
+
supersampled, [shift_y_os, shift_x_os], order=3, mode='constant', cval=0
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
shifted = supersampled
|
|
518
|
+
|
|
519
|
+
# Sum N×N blocks
|
|
520
|
+
out_h = supersampled.shape[0] // N
|
|
521
|
+
out_w = supersampled.shape[1] // N
|
|
522
|
+
stamp = shifted[: out_h * N, : out_w * N].reshape(out_h, N, out_w, N).sum(axis=(1, 3))
|
|
523
|
+
else:
|
|
524
|
+
# Fallback for non-integer oversampling or oversampling=1
|
|
525
|
+
ssx0 = (supersampled.shape[1] - 1) / 2.0
|
|
526
|
+
ssy0 = (supersampled.shape[0] - 1) / 2.0
|
|
527
|
+
|
|
528
|
+
x0 = np.floor(psf['width'] * psf['sampling'] / 2)
|
|
529
|
+
y0 = np.floor(psf['height'] * psf['sampling'] / 2)
|
|
530
|
+
|
|
531
|
+
width = int(x0) * 2 + 1
|
|
532
|
+
height = int(y0) * 2 + 1
|
|
533
|
+
|
|
534
|
+
x0 += dx
|
|
535
|
+
y0 += dy
|
|
536
|
+
|
|
537
|
+
y, x = np.mgrid[0:height, 0:width]
|
|
538
|
+
|
|
539
|
+
x1 = ssx0 + (x - x0) / psf['sampling']
|
|
540
|
+
y1 = ssy0 + (y - y0) / psf['sampling']
|
|
541
|
+
|
|
542
|
+
stamp = ndimage.map_coordinates(supersampled, [y1, x1], order=3) / psf['sampling'] ** 2
|
|
543
|
+
|
|
544
|
+
if normalize:
|
|
545
|
+
total = np.sum(stamp)
|
|
546
|
+
if np.isfinite(total) and total > 0:
|
|
547
|
+
stamp /= total
|
|
548
|
+
|
|
549
|
+
return stamp
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def place_psf_stamp(image, psf, x0, y0, flux=1, gain=None):
|
|
553
|
+
"""Places PSF stamp, scaled to a given flux, at a given position inside the image.
|
|
554
|
+
|
|
555
|
+
PSF stamp is evaluated at a given position, then adjusted to accommodate for
|
|
556
|
+
required sub-pixel shift, and finally scaled to requested flux value. Thus,
|
|
557
|
+
the routine corresponds to injection of an artificial point source into the image.
|
|
558
|
+
|
|
559
|
+
The stamp values are added on top of current content of the image.
|
|
560
|
+
If `gain` value is set, the Poissonian noise is applied to the stamp.
|
|
561
|
+
|
|
562
|
+
The image is modified in-place.
|
|
563
|
+
|
|
564
|
+
Parameters
|
|
565
|
+
----------
|
|
566
|
+
image : numpy.ndarray
|
|
567
|
+
The image where the artificial source will be injected (modified in-place).
|
|
568
|
+
psf : dict
|
|
569
|
+
Input PSF structure as returned by :func:`stdpipe.psf.run_psfex` or
|
|
570
|
+
:func:`stdpipe.psf.load_psf`.
|
|
571
|
+
x0 : float
|
|
572
|
+
``x`` coordinate of the position to inject the source.
|
|
573
|
+
y0 : float
|
|
574
|
+
``y`` coordinate of the position to inject the source.
|
|
575
|
+
flux : float, optional
|
|
576
|
+
The source flux in ADU units.
|
|
577
|
+
gain : float, optional
|
|
578
|
+
Image gain value. If set, used to apply Poissonian noise to the source.
|
|
579
|
+
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
stamp = get_psf_stamp(psf, x0, y0, normalize=True)
|
|
583
|
+
stamp *= flux
|
|
584
|
+
|
|
585
|
+
if gain is not None:
|
|
586
|
+
idx = stamp > 0
|
|
587
|
+
# FIXME: what to do with negative points?..
|
|
588
|
+
stamp[idx] = np.random.poisson(stamp[idx] * gain) / gain
|
|
589
|
+
|
|
590
|
+
# Integer coordinates inside the stamp
|
|
591
|
+
y, x = np.mgrid[0 : stamp.shape[0], 0 : stamp.shape[1]]
|
|
592
|
+
|
|
593
|
+
# Corresponding image pixels
|
|
594
|
+
y1, x1 = np.mgrid[0 : stamp.shape[0], 0 : stamp.shape[1]]
|
|
595
|
+
x1 += int(np.round(x0) - np.floor(stamp.shape[1] / 2))
|
|
596
|
+
y1 += int(np.round(y0) - np.floor(stamp.shape[0] / 2))
|
|
597
|
+
|
|
598
|
+
# Crop the coordinates outside target image
|
|
599
|
+
idx = np.isfinite(stamp)
|
|
600
|
+
idx &= (x1 >= 0) & (x1 < image.shape[1])
|
|
601
|
+
idx &= (y1 >= 0) & (y1 < image.shape[0])
|
|
602
|
+
|
|
603
|
+
# Add the stamp to the image!
|
|
604
|
+
image[y1[idx], x1[idx]] += stamp[y[idx], x[idx]]
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def create_psf_model(
|
|
608
|
+
image,
|
|
609
|
+
obj=None,
|
|
610
|
+
fwhm=None,
|
|
611
|
+
size=None,
|
|
612
|
+
mask=None,
|
|
613
|
+
oversampling=None,
|
|
614
|
+
degree=0,
|
|
615
|
+
regularization=1e-6,
|
|
616
|
+
subtract_neighbors=True,
|
|
617
|
+
neighbors_obj=None,
|
|
618
|
+
subtract_background=False,
|
|
619
|
+
isolation=2.0,
|
|
620
|
+
get_raw=False,
|
|
621
|
+
verbose=False,
|
|
622
|
+
):
|
|
623
|
+
"""
|
|
624
|
+
Create an empirical PSF (ePSF) model from stars in the image.
|
|
625
|
+
|
|
626
|
+
For ``degree=0`` (default), builds a position-invariant ePSF using
|
|
627
|
+
photutils ``EPSFBuilder`` (iterative recentering and stacking).
|
|
628
|
+
|
|
629
|
+
For ``degree > 0``, builds a position-dependent PSF model by fitting
|
|
630
|
+
per-pixel polynomial coefficients to resampled star stamps, following
|
|
631
|
+
the same approach as PSFEx. The polynomial model is:
|
|
632
|
+
|
|
633
|
+
.. math::
|
|
634
|
+
|
|
635
|
+
PSF(i,j; x,y) = \\sum_k c_k(i,j) \\cdot dx^{p1_k} \\cdot dy^{p2_k}
|
|
636
|
+
|
|
637
|
+
where ``(dx, dy)`` are normalized image coordinates and ``(p1_k, p2_k)``
|
|
638
|
+
are polynomial exponents with ``p1_k + p2_k <= degree``.
|
|
639
|
+
|
|
640
|
+
The returned dictionary structure is compatible with PSFEx output from
|
|
641
|
+
:func:`stdpipe.psf.run_psfex` and can be used with the same evaluation
|
|
642
|
+
functions like :func:`stdpipe.psf.get_psf_stamp`.
|
|
643
|
+
|
|
644
|
+
Parameters
|
|
645
|
+
----------
|
|
646
|
+
image : numpy.ndarray
|
|
647
|
+
Input image as a NumPy array, must be background subtracted.
|
|
648
|
+
obj : astropy.table.Table, optional
|
|
649
|
+
Table of star positions. If None, stars will be detected automatically.
|
|
650
|
+
Should have 'x', 'y' columns and optionally 'flux'.
|
|
651
|
+
fwhm : float, optional
|
|
652
|
+
Approximate FWHM of stars in pixels. If None, will be estimated.
|
|
653
|
+
size : int, optional
|
|
654
|
+
Size of cutouts to extract around stars (should be odd). If None, automatically
|
|
655
|
+
determined from FWHM as ``max(15, round_up_to_odd(5 * fwhm))``.
|
|
656
|
+
mask : numpy.ndarray, optional
|
|
657
|
+
Image mask as a boolean array (True values will be masked).
|
|
658
|
+
oversampling : int, optional
|
|
659
|
+
Oversampling factor for the ePSF. If None (default), it is auto-selected from
|
|
660
|
+
the FWHM: ``1`` when ``fwhm >= 2.5`` image pixels (well-sampled PSF) and
|
|
661
|
+
``2`` otherwise (under-sampled PSF). Pass an explicit integer to override.
|
|
662
|
+
degree : int, optional
|
|
663
|
+
Polynomial degree for spatial PSF variation (default: 0 = constant). Degree 1 =
|
|
664
|
+
linear (3 coefficients), degree 2 = quadratic (6 coefficients), etc.
|
|
665
|
+
regularization : float, optional
|
|
666
|
+
Tikhonov regularization parameter for polynomial fitting (default: 1e-6). Only used
|
|
667
|
+
when ``degree > 0``. Set to 0 for unregularized least-squares.
|
|
668
|
+
subtract_neighbors : bool, optional
|
|
669
|
+
If True (default), subtract estimated flux from neighboring stars before extracting
|
|
670
|
+
cutouts. Reduces contamination in crowded fields. Only used when ``degree > 0``.
|
|
671
|
+
neighbors_obj : astropy.table.Table, optional
|
|
672
|
+
Full detection catalogue (with 'x', 'y', 'flux' columns) used to model
|
|
673
|
+
neighbour contamination when ``subtract_neighbors=True``. If None, the
|
|
674
|
+
full auto-detected list is used when ``obj`` is None, otherwise ``obj``
|
|
675
|
+
itself. Passing the complete catalogue matters when ``obj`` is a
|
|
676
|
+
pre-selected subset (e.g. from :func:`select_psf_seeds`) — the stars
|
|
677
|
+
that actually contaminate the stamps are usually not in that subset.
|
|
678
|
+
subtract_background : bool or {'none', 'median', 'plane'}, optional
|
|
679
|
+
Local background handling for each training stamp before normalization. ``False`` or
|
|
680
|
+
``'none'`` leaves the current image values unchanged, ``True`` or ``'median'``
|
|
681
|
+
subtracts the median of the stamp border, and ``'plane'`` fits and subtracts a
|
|
682
|
+
tilted background plane from the border pixels. Only used when ``degree > 0``.
|
|
683
|
+
When building from the raw image instead of a background-subtracted one,
|
|
684
|
+
``'plane'`` is usually the most robust choice.
|
|
685
|
+
isolation : float, optional
|
|
686
|
+
Minimum nearest-neighbor distance in FWHM units for selecting stars for ePSF building
|
|
687
|
+
(default: 2.0). Stars with a neighbor closer than ``isolation * fwhm`` are excluded.
|
|
688
|
+
Combined with ``subtract_neighbors=True``, the lower default value works in both
|
|
689
|
+
sparse and dense fields. Set to 0 or None to disable isolation filtering.
|
|
690
|
+
get_raw : bool, optional
|
|
691
|
+
If True and ``degree=0``, returns raw photutils EPSFModel object. Ignored when
|
|
692
|
+
``degree > 0``.
|
|
693
|
+
verbose : bool or callable, optional
|
|
694
|
+
Whether to show verbose messages.
|
|
695
|
+
|
|
696
|
+
Returns
|
|
697
|
+
-------
|
|
698
|
+
dict
|
|
699
|
+
Dictionary with PSFEx-compatible structure containing the PSF model.
|
|
700
|
+
|
|
701
|
+
"""
|
|
702
|
+
|
|
703
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
704
|
+
|
|
705
|
+
# Detect stars if not provided
|
|
706
|
+
if obj is None:
|
|
707
|
+
log('Detecting stars for ePSF building')
|
|
708
|
+
obj = photometry.get_objects_sep(image, mask=mask, thresh=5.0, aper=3.0, verbose=verbose)
|
|
709
|
+
|
|
710
|
+
# Select isolated, bright, non-saturated stars
|
|
711
|
+
# Simple selection: median flux and not too crowded
|
|
712
|
+
if len(obj) == 0:
|
|
713
|
+
raise ValueError("No stars detected for ePSF building")
|
|
714
|
+
|
|
715
|
+
# Keep the full detection list for neighbour modelling before the
|
|
716
|
+
# quality/isolation selection below removes the real neighbours
|
|
717
|
+
if neighbors_obj is None:
|
|
718
|
+
neighbors_obj = obj
|
|
719
|
+
|
|
720
|
+
# Determine FWHM and stamp size early so we can use them for edge filtering
|
|
721
|
+
if fwhm is None:
|
|
722
|
+
if 'fwhm' in obj.colnames:
|
|
723
|
+
fwhm = np.median(obj['fwhm'])
|
|
724
|
+
log('Using median FWHM: %.2f pixels' % fwhm)
|
|
725
|
+
else:
|
|
726
|
+
fwhm = 3.0
|
|
727
|
+
log('FWHM not available, using default: %.2f pixels' % fwhm)
|
|
728
|
+
|
|
729
|
+
if size is None:
|
|
730
|
+
size = max(15, int(np.ceil(5 * fwhm)))
|
|
731
|
+
if size % 2 == 0:
|
|
732
|
+
size += 1
|
|
733
|
+
|
|
734
|
+
flux_median = np.median(obj['flux'])
|
|
735
|
+
# np.std is inflated by the bright tail, so a median + N*std upper
|
|
736
|
+
# bound lets saturated stars through; cut the brightest few percent
|
|
737
|
+
# instead, which is where saturated stars live
|
|
738
|
+
flux_upper = np.percentile(obj['flux'], 98)
|
|
739
|
+
|
|
740
|
+
# Select stars with flux within reasonable range
|
|
741
|
+
idx = (obj['flux'] > flux_median) & (obj['flux'] < flux_upper)
|
|
742
|
+
# Remove edge objects
|
|
743
|
+
edge = size
|
|
744
|
+
idx &= (obj['x'] > edge) & (obj['x'] < image.shape[1] - edge)
|
|
745
|
+
idx &= (obj['y'] > edge) & (obj['y'] < image.shape[0] - edge)
|
|
746
|
+
# Remove flagged objects
|
|
747
|
+
if 'flags' in obj.colnames:
|
|
748
|
+
idx &= obj['flags'] == 0
|
|
749
|
+
|
|
750
|
+
obj = obj[idx]
|
|
751
|
+
log('Selected %d stars for ePSF building' % len(obj))
|
|
752
|
+
|
|
753
|
+
if fwhm is None:
|
|
754
|
+
if 'fwhm' in obj.colnames:
|
|
755
|
+
fwhm = np.median(obj['fwhm'])
|
|
756
|
+
log('Using median FWHM: %.2f pixels' % fwhm)
|
|
757
|
+
else:
|
|
758
|
+
fwhm = 3.0
|
|
759
|
+
log('FWHM not available, using default: %.2f pixels' % fwhm)
|
|
760
|
+
|
|
761
|
+
# Auto-size stamps based on FWHM if not specified.
|
|
762
|
+
# 5*FWHM keeps the stamp small enough to be cheap in sep.psf_fit while
|
|
763
|
+
# still covering ~3-sigma of the PSF wings. The 8*FWHM legacy default
|
|
764
|
+
# over-extends in dense fields (large fit groups, slow rendering).
|
|
765
|
+
if size is None:
|
|
766
|
+
size = max(15, int(np.ceil(5 * fwhm)))
|
|
767
|
+
if size % 2 == 0:
|
|
768
|
+
size += 1 # Make sure size is odd
|
|
769
|
+
log('Using stamp size: %d pixels (FWHM=%.1f)' % (size, fwhm))
|
|
770
|
+
|
|
771
|
+
# Auto-pick oversampling from FWHM if not explicitly set.
|
|
772
|
+
# FWHM >= 2.5 image pixels is well-sampled enough that oversampling=1 is
|
|
773
|
+
# adequate (saves model storage and SEP rendering cost). Smaller FWHM
|
|
774
|
+
# (under-sampled PSFs) needs oversampling=2 to avoid pixelisation bias.
|
|
775
|
+
if oversampling is None:
|
|
776
|
+
oversampling = 1 if fwhm >= 2.5 else 2
|
|
777
|
+
log('Auto-selected oversampling=%d (FWHM=%.2f pix)' % (oversampling, fwhm))
|
|
778
|
+
|
|
779
|
+
# Filter by isolation: reject stars with a neighbor closer than isolation * fwhm.
|
|
780
|
+
# Neighbor contamination in star stamps is the primary source of ePSF bias
|
|
781
|
+
# in crowded fields; selecting only isolated stars dramatically improves
|
|
782
|
+
# ePSF quality (tested: reduces bias from +30% to -1% at 6 FWHM separation).
|
|
783
|
+
if isolation and isolation > 0 and len(obj) > 1:
|
|
784
|
+
from scipy.spatial import cKDTree
|
|
785
|
+
|
|
786
|
+
min_dist = isolation * fwhm
|
|
787
|
+
tree = cKDTree(np.c_[obj['x'], obj['y']])
|
|
788
|
+
nn_dist = tree.query(np.c_[obj['x'], obj['y']], k=2)[0][:, 1]
|
|
789
|
+
isolated = obj[nn_dist > min_dist]
|
|
790
|
+
n_before = len(obj)
|
|
791
|
+
if len(isolated) >= 10:
|
|
792
|
+
obj = isolated
|
|
793
|
+
log(
|
|
794
|
+
'Isolation filter (>%.0f*FWHM = >%.1f px): %d / %d stars selected'
|
|
795
|
+
% (isolation, min_dist, len(obj), n_before)
|
|
796
|
+
)
|
|
797
|
+
else:
|
|
798
|
+
# Not enough isolated stars; fall back to the most isolated ones
|
|
799
|
+
n_fallback = max(10, n_before // 5)
|
|
800
|
+
idx = np.argsort(-nn_dist)[:n_fallback]
|
|
801
|
+
obj = obj[idx]
|
|
802
|
+
log(
|
|
803
|
+
'Isolation filter: only %d stars with >%.1f px separation; '
|
|
804
|
+
'using %d most isolated instead' % (len(isolated), min_dist, len(obj))
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
background_mode = _normalize_stamp_background_mode(subtract_background)
|
|
808
|
+
|
|
809
|
+
if degree == 0:
|
|
810
|
+
if background_mode != 'none':
|
|
811
|
+
log('Ignoring local stamp background mode for position-invariant ePSF builder')
|
|
812
|
+
return _create_psf_model_constant(
|
|
813
|
+
image, obj, fwhm, size, mask, oversampling, get_raw, verbose, log
|
|
814
|
+
)
|
|
815
|
+
else:
|
|
816
|
+
return _create_psf_model_polynomial(
|
|
817
|
+
image,
|
|
818
|
+
obj,
|
|
819
|
+
fwhm,
|
|
820
|
+
size,
|
|
821
|
+
mask,
|
|
822
|
+
oversampling,
|
|
823
|
+
degree,
|
|
824
|
+
regularization,
|
|
825
|
+
subtract_neighbors,
|
|
826
|
+
neighbors_obj if neighbors_obj is not None else obj,
|
|
827
|
+
background_mode,
|
|
828
|
+
log,
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _create_psf_model_constant(image, obj, fwhm, size, mask, oversampling, get_raw, verbose, log):
|
|
833
|
+
"""Build position-invariant ePSF using photutils EPSFBuilder."""
|
|
834
|
+
|
|
835
|
+
log('Extracting %dx%d cutouts around %d stars' % (size, size, len(obj)))
|
|
836
|
+
|
|
837
|
+
nddata = NDData(data=image, mask=mask)
|
|
838
|
+
|
|
839
|
+
# Extract stars using photutils
|
|
840
|
+
stars = photutils.psf.extract_stars(nddata, Table({'x': obj['x'], 'y': obj['y']}), size=size)
|
|
841
|
+
|
|
842
|
+
# Build ePSF
|
|
843
|
+
log('Building ePSF model with oversampling=%d' % oversampling)
|
|
844
|
+
epsf_builder = photutils.psf.EPSFBuilder(
|
|
845
|
+
oversampling=oversampling, maxiters=10, progress_bar=bool(verbose)
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
epsf, fitted_stars = epsf_builder(stars)
|
|
849
|
+
|
|
850
|
+
log('ePSF building complete. PSF shape: %s' % str(epsf.data.shape))
|
|
851
|
+
|
|
852
|
+
if get_raw:
|
|
853
|
+
return epsf
|
|
854
|
+
|
|
855
|
+
# Build PSFEx-compatible structure
|
|
856
|
+
psf_data = epsf.data
|
|
857
|
+
|
|
858
|
+
# Reshape to (ncoeffs, height, width) format
|
|
859
|
+
# ePSF is position-invariant, so ncoeffs=1
|
|
860
|
+
if psf_data.ndim == 2:
|
|
861
|
+
psf_data = psf_data[np.newaxis, :, :] # Add coefficient dimension
|
|
862
|
+
|
|
863
|
+
psf = {
|
|
864
|
+
'width': psf_data.shape[2],
|
|
865
|
+
'height': psf_data.shape[1],
|
|
866
|
+
'fwhm': fwhm,
|
|
867
|
+
'sampling': 1.0 / oversampling, # PSFEx convention: < 1 means supersampled
|
|
868
|
+
'ncoeffs': 1,
|
|
869
|
+
'degree': 0, # Constant PSF (no position dependence)
|
|
870
|
+
'x0': 0,
|
|
871
|
+
'y0': 0,
|
|
872
|
+
'sx': 1,
|
|
873
|
+
'sy': 1,
|
|
874
|
+
'data': psf_data,
|
|
875
|
+
'oversampling': oversampling, # Keep for reference
|
|
876
|
+
'type': 'epsf', # Identify PSF type
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return psf
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _build_polynomial_psf_taper(reference_stamp, sampling, fwhm):
|
|
883
|
+
"""Build a smooth radial taper for low-S/N outer PSF pixels."""
|
|
884
|
+
|
|
885
|
+
h, w = reference_stamp.shape
|
|
886
|
+
y, x = np.mgrid[0:h, 0:w]
|
|
887
|
+
cx = 0.5 * (w - 1)
|
|
888
|
+
cy = 0.5 * (h - 1)
|
|
889
|
+
r = np.sqrt(((x - cx) * sampling) ** 2 + ((y - cy) * sampling) ** 2)
|
|
890
|
+
|
|
891
|
+
peak = float(np.nanmax(reference_stamp))
|
|
892
|
+
half_width = 0.5 * (min(h, w) - 1) * sampling
|
|
893
|
+
taper_start = 0.75 * half_width
|
|
894
|
+
threshold = 0.02 * peak
|
|
895
|
+
|
|
896
|
+
if np.isfinite(peak) and peak > 0:
|
|
897
|
+
edges = np.arange(0, np.max(r) + sampling, sampling)
|
|
898
|
+
centers = 0.5 * (edges[:-1] + edges[1:])
|
|
899
|
+
profile = []
|
|
900
|
+
for r0, r1 in zip(edges[:-1], edges[1:]):
|
|
901
|
+
idx = (r >= r0) & (r < r1)
|
|
902
|
+
profile.append(np.nanmedian(reference_stamp[idx]) if np.any(idx) else np.nan)
|
|
903
|
+
profile = np.asarray(profile, float)
|
|
904
|
+
idx = np.where(np.isfinite(profile) & (profile <= threshold))[0]
|
|
905
|
+
if len(idx):
|
|
906
|
+
taper_start = float(centers[idx[0]])
|
|
907
|
+
|
|
908
|
+
taper_start = float(
|
|
909
|
+
np.clip(
|
|
910
|
+
taper_start,
|
|
911
|
+
max(1.25 * fwhm, sampling),
|
|
912
|
+
max(1.25 * fwhm, half_width - 2 * sampling),
|
|
913
|
+
)
|
|
914
|
+
)
|
|
915
|
+
taper_width = max(half_width - taper_start, 0.0)
|
|
916
|
+
|
|
917
|
+
if taper_width <= 0:
|
|
918
|
+
return np.ones_like(reference_stamp), taper_start, taper_width
|
|
919
|
+
|
|
920
|
+
window = np.ones_like(reference_stamp)
|
|
921
|
+
idx = r > taper_start
|
|
922
|
+
if np.any(idx):
|
|
923
|
+
phase = np.clip((r[idx] - taper_start) / taper_width, 0.0, 1.0)
|
|
924
|
+
window[idx] = 0.5 * (1.0 + np.cos(np.pi * phase))
|
|
925
|
+
window[r >= half_width] = 0.0
|
|
926
|
+
|
|
927
|
+
return window, taper_start, taper_width
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _regularize_polynomial_psf(coeffs, stamps_array, keep, sampling, fwhm):
|
|
931
|
+
"""Suppress spurious outer support and restore polynomial PSF normalization."""
|
|
932
|
+
|
|
933
|
+
import warnings
|
|
934
|
+
|
|
935
|
+
with warnings.catch_warnings():
|
|
936
|
+
# All-NaN pixels (masked in every kept stamp) are expected here
|
|
937
|
+
warnings.simplefilter('ignore', RuntimeWarning)
|
|
938
|
+
reference_stamp = np.nanmedian(stamps_array[keep], axis=0)
|
|
939
|
+
reference_stamp = np.where(np.isfinite(reference_stamp), reference_stamp, 0.0)
|
|
940
|
+
window, taper_start, taper_width = _build_polynomial_psf_taper(reference_stamp, sampling, fwhm)
|
|
941
|
+
coeffs *= window[np.newaxis, :, :]
|
|
942
|
+
|
|
943
|
+
template = np.clip(reference_stamp * window, 0.0, None)
|
|
944
|
+
total = float(np.sum(template))
|
|
945
|
+
if not np.isfinite(total) or total <= 0:
|
|
946
|
+
template = window.copy()
|
|
947
|
+
total = float(np.sum(template))
|
|
948
|
+
template /= total
|
|
949
|
+
|
|
950
|
+
plane_sums = coeffs.reshape(coeffs.shape[0], -1).sum(axis=1)
|
|
951
|
+
target_sums = np.zeros_like(plane_sums)
|
|
952
|
+
target_sums[0] = 1.0
|
|
953
|
+
coeffs += (target_sums - plane_sums)[:, np.newaxis, np.newaxis] * template[np.newaxis, :, :]
|
|
954
|
+
|
|
955
|
+
return coeffs, taper_start, taper_width
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _normalize_stamp_background_mode(mode):
|
|
959
|
+
"""Normalize public stamp-background options to internal mode names."""
|
|
960
|
+
|
|
961
|
+
if mode is None:
|
|
962
|
+
return 'none'
|
|
963
|
+
if isinstance(mode, (bool, np.bool_)):
|
|
964
|
+
return 'median' if bool(mode) else 'none'
|
|
965
|
+
if isinstance(mode, str):
|
|
966
|
+
normalized = mode.strip().lower()
|
|
967
|
+
aliases = {
|
|
968
|
+
'none': 'none',
|
|
969
|
+
'median': 'median',
|
|
970
|
+
'edge': 'median',
|
|
971
|
+
'constant': 'median',
|
|
972
|
+
'plane': 'plane',
|
|
973
|
+
'tilt': 'plane',
|
|
974
|
+
}
|
|
975
|
+
if normalized in aliases:
|
|
976
|
+
return aliases[normalized]
|
|
977
|
+
|
|
978
|
+
raise ValueError(
|
|
979
|
+
"subtract_background should be False/True or one of "
|
|
980
|
+
"{'none', 'median', 'plane'}"
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _subtract_local_stamp_background(cutout, mask_cutout=None, mode='none', border=2):
|
|
985
|
+
"""Remove a local constant or tilted background from a stellar cutout."""
|
|
986
|
+
|
|
987
|
+
mode = _normalize_stamp_background_mode(mode)
|
|
988
|
+
if mode == 'none':
|
|
989
|
+
return cutout
|
|
990
|
+
|
|
991
|
+
h, w = cutout.shape
|
|
992
|
+
border = int(np.clip(border, 1, max(1, min(h, w) // 2)))
|
|
993
|
+
|
|
994
|
+
edge = np.zeros_like(cutout, dtype=bool)
|
|
995
|
+
edge[:border, :] = True
|
|
996
|
+
edge[-border:, :] = True
|
|
997
|
+
edge[:, :border] = True
|
|
998
|
+
edge[:, -border:] = True
|
|
999
|
+
|
|
1000
|
+
valid = edge & np.isfinite(cutout)
|
|
1001
|
+
if mask_cutout is not None:
|
|
1002
|
+
valid &= ~np.asarray(mask_cutout, bool)
|
|
1003
|
+
|
|
1004
|
+
if not np.any(valid):
|
|
1005
|
+
return cutout
|
|
1006
|
+
|
|
1007
|
+
values = cutout[valid]
|
|
1008
|
+
if mode == 'median' or np.sum(valid) < 6:
|
|
1009
|
+
cutout -= np.median(values)
|
|
1010
|
+
return cutout
|
|
1011
|
+
|
|
1012
|
+
yy, xx = np.mgrid[0:h, 0:w]
|
|
1013
|
+
xx0 = xx.astype(np.float64) - 0.5 * (w - 1)
|
|
1014
|
+
yy0 = yy.astype(np.float64) - 0.5 * (h - 1)
|
|
1015
|
+
|
|
1016
|
+
x = xx0[valid]
|
|
1017
|
+
y = yy0[valid]
|
|
1018
|
+
z = values.astype(np.float64)
|
|
1019
|
+
keep = np.ones(len(z), dtype=bool)
|
|
1020
|
+
|
|
1021
|
+
for _ in range(2):
|
|
1022
|
+
if np.sum(keep) < 3:
|
|
1023
|
+
break
|
|
1024
|
+
|
|
1025
|
+
A = np.column_stack([np.ones(np.sum(keep)), x[keep], y[keep]])
|
|
1026
|
+
coeffs, _, _, _ = np.linalg.lstsq(A, z[keep], rcond=None)
|
|
1027
|
+
model = coeffs[0] + coeffs[1] * x + coeffs[2] * y
|
|
1028
|
+
resid = z - model
|
|
1029
|
+
med = np.median(resid[keep])
|
|
1030
|
+
mad = np.median(np.abs(resid[keep] - med)) * 1.4826
|
|
1031
|
+
|
|
1032
|
+
if not np.isfinite(mad) or mad <= 0:
|
|
1033
|
+
break
|
|
1034
|
+
|
|
1035
|
+
new_keep = np.abs(resid - med) <= 3.0 * mad
|
|
1036
|
+
if np.sum(new_keep) < 3 or np.array_equal(new_keep, keep):
|
|
1037
|
+
break
|
|
1038
|
+
keep = new_keep
|
|
1039
|
+
|
|
1040
|
+
if np.sum(keep) < 3:
|
|
1041
|
+
cutout -= np.median(values)
|
|
1042
|
+
return cutout
|
|
1043
|
+
|
|
1044
|
+
A = np.column_stack([np.ones(np.sum(keep)), x[keep], y[keep]])
|
|
1045
|
+
coeffs, _, _, _ = np.linalg.lstsq(A, z[keep], rcond=None)
|
|
1046
|
+
background = coeffs[0] + coeffs[1] * xx0 + coeffs[2] * yy0
|
|
1047
|
+
cutout -= background
|
|
1048
|
+
|
|
1049
|
+
return cutout
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _create_psf_model_polynomial(
|
|
1053
|
+
image,
|
|
1054
|
+
obj,
|
|
1055
|
+
fwhm,
|
|
1056
|
+
size,
|
|
1057
|
+
mask,
|
|
1058
|
+
oversampling,
|
|
1059
|
+
degree,
|
|
1060
|
+
regularization,
|
|
1061
|
+
subtract_neighbors,
|
|
1062
|
+
neighbors,
|
|
1063
|
+
background_mode,
|
|
1064
|
+
log,
|
|
1065
|
+
):
|
|
1066
|
+
"""Build position-dependent PSF by fitting per-pixel polynomials to star stamps.
|
|
1067
|
+
|
|
1068
|
+
Follows the PSFEx algorithm: resamples star cutouts onto an oversampled
|
|
1069
|
+
grid, then fits polynomial coefficients per pixel via least squares.
|
|
1070
|
+
"""
|
|
1071
|
+
|
|
1072
|
+
ncoeffs = (degree + 1) * (degree + 2) // 2
|
|
1073
|
+
log('Building position-dependent PSF model: degree=%d (%d coefficients)' % (degree, ncoeffs))
|
|
1074
|
+
if background_mode != 'none':
|
|
1075
|
+
log('Subtracting local stamp background using %s model' % background_mode)
|
|
1076
|
+
|
|
1077
|
+
if len(obj) < ncoeffs:
|
|
1078
|
+
raise ValueError(
|
|
1079
|
+
"Need at least %d stars for degree=%d polynomial, got %d" % (ncoeffs, degree, len(obj))
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
sampling = 1.0 / oversampling
|
|
1083
|
+
|
|
1084
|
+
# Oversampled stamp size (keep odd for centered stamps)
|
|
1085
|
+
os_size = size * oversampling
|
|
1086
|
+
if os_size % 2 == 0:
|
|
1087
|
+
os_size += 1
|
|
1088
|
+
os_center = (os_size - 1) / 2.0
|
|
1089
|
+
cut_center = (size - 1) / 2.0
|
|
1090
|
+
|
|
1091
|
+
# Normalization parameters: image center and half-size
|
|
1092
|
+
# This maps coordinates to approximately [-1, 1] range
|
|
1093
|
+
x0 = image.shape[1] / 2.0
|
|
1094
|
+
y0 = image.shape[0] / 2.0
|
|
1095
|
+
sx = image.shape[1] / 2.0
|
|
1096
|
+
sy = image.shape[0] / 2.0
|
|
1097
|
+
|
|
1098
|
+
# Oversampled coordinate grid (computed once, reused for all stamps)
|
|
1099
|
+
oy, ox = np.mgrid[0:os_size, 0:os_size]
|
|
1100
|
+
ox_rel = (ox - os_center) * sampling # relative to stamp center, in image pixels
|
|
1101
|
+
oy_rel = (oy - os_center) * sampling
|
|
1102
|
+
|
|
1103
|
+
# Build neighbor subtraction data if requested
|
|
1104
|
+
# We subtract Gaussian approximations of all OTHER detections (from the
|
|
1105
|
+
# full catalogue, not just the training stars) from each cutout
|
|
1106
|
+
if subtract_neighbors and neighbors is not None and 'flux' in neighbors.colnames:
|
|
1107
|
+
sigma = fwhm / 2.3548 # FWHM to sigma
|
|
1108
|
+
nb_x = np.array(neighbors['x'], dtype=np.float64)
|
|
1109
|
+
nb_y = np.array(neighbors['y'], dtype=np.float64)
|
|
1110
|
+
nb_flux = np.array(neighbors['flux'], dtype=np.float64)
|
|
1111
|
+
nb_ok = np.isfinite(nb_x) & np.isfinite(nb_y) & np.isfinite(nb_flux) & (nb_flux > 0)
|
|
1112
|
+
nb_x, nb_y, nb_flux = nb_x[nb_ok], nb_y[nb_ok], nb_flux[nb_ok]
|
|
1113
|
+
else:
|
|
1114
|
+
subtract_neighbors = False
|
|
1115
|
+
|
|
1116
|
+
# Extract and resample stamps
|
|
1117
|
+
stamps = []
|
|
1118
|
+
stamp_valids = []
|
|
1119
|
+
positions_x = []
|
|
1120
|
+
positions_y = []
|
|
1121
|
+
stamp_fluxes = []
|
|
1122
|
+
half = size // 2
|
|
1123
|
+
|
|
1124
|
+
for i in range(len(obj)):
|
|
1125
|
+
x_star = float(obj['x'][i])
|
|
1126
|
+
y_star = float(obj['y'][i])
|
|
1127
|
+
|
|
1128
|
+
# Integer center and sub-pixel offset
|
|
1129
|
+
ix = int(np.round(x_star))
|
|
1130
|
+
iy = int(np.round(y_star))
|
|
1131
|
+
dx = x_star - ix
|
|
1132
|
+
dy = y_star - iy
|
|
1133
|
+
|
|
1134
|
+
# Cutout boundaries
|
|
1135
|
+
x1, x2 = ix - half, ix + half + 1
|
|
1136
|
+
y1, y2 = iy - half, iy + half + 1
|
|
1137
|
+
|
|
1138
|
+
# Skip if cutout extends beyond image
|
|
1139
|
+
if x1 < 0 or x2 > image.shape[1] or y1 < 0 or y2 > image.shape[0]:
|
|
1140
|
+
continue
|
|
1141
|
+
|
|
1142
|
+
cutout = image[y1:y2, x1:x2].astype(np.float64).copy()
|
|
1143
|
+
|
|
1144
|
+
# Subtract estimated neighbor flux from cutout
|
|
1145
|
+
if subtract_neighbors:
|
|
1146
|
+
# Pixel coordinate grids for this cutout
|
|
1147
|
+
cy, cx = np.mgrid[y1:y2, x1:x2]
|
|
1148
|
+
for j in range(len(nb_x)):
|
|
1149
|
+
# Skip the target star itself (matched by position, as the
|
|
1150
|
+
# neighbour catalogue may differ from the training list)
|
|
1151
|
+
if abs(nb_x[j] - x_star) < 0.5 and abs(nb_y[j] - y_star) < 0.5:
|
|
1152
|
+
continue
|
|
1153
|
+
# Skip neighbors too far to affect this cutout
|
|
1154
|
+
ndx = nb_x[j] - ix
|
|
1155
|
+
ndy = nb_y[j] - iy
|
|
1156
|
+
if abs(ndx) > half + 5 * sigma or abs(ndy) > half + 5 * sigma:
|
|
1157
|
+
continue
|
|
1158
|
+
# Subtract Gaussian approximation
|
|
1159
|
+
r2 = (cx - nb_x[j]) ** 2 + (cy - nb_y[j]) ** 2
|
|
1160
|
+
amp = nb_flux[j] / (2 * np.pi * sigma**2)
|
|
1161
|
+
cutout -= amp * np.exp(-r2 / (2 * sigma**2))
|
|
1162
|
+
|
|
1163
|
+
# Skip if mask has too many bad pixels in this cutout
|
|
1164
|
+
mask_cutout = None
|
|
1165
|
+
if mask is not None:
|
|
1166
|
+
mask_cutout = mask[y1:y2, x1:x2]
|
|
1167
|
+
if np.sum(mask_cutout) > 0.1 * cutout.size:
|
|
1168
|
+
continue
|
|
1169
|
+
cutout[mask_cutout] = 0.0
|
|
1170
|
+
|
|
1171
|
+
if background_mode != 'none':
|
|
1172
|
+
cutout = _subtract_local_stamp_background(cutout, mask_cutout, mode=background_mode)
|
|
1173
|
+
|
|
1174
|
+
# Resample to oversampled grid, centering star at stamp center
|
|
1175
|
+
# Map each oversampled pixel back to cutout coordinates
|
|
1176
|
+
cutout_x = cut_center + dx + ox_rel
|
|
1177
|
+
cutout_y = cut_center + dy + oy_rel
|
|
1178
|
+
|
|
1179
|
+
stamp = ndimage.map_coordinates(
|
|
1180
|
+
cutout, [cutout_y, cutout_x], order=3, mode='constant', cval=0.0
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
# Track oversampled pixels touched by masked image pixels, so the
|
|
1184
|
+
# per-pixel polynomial fit can exclude them instead of being dragged
|
|
1185
|
+
# towards the zero-filled values
|
|
1186
|
+
if mask_cutout is not None and np.any(mask_cutout):
|
|
1187
|
+
valid = (
|
|
1188
|
+
ndimage.map_coordinates(
|
|
1189
|
+
(~mask_cutout).astype(np.float64),
|
|
1190
|
+
[cutout_y, cutout_x],
|
|
1191
|
+
order=1,
|
|
1192
|
+
mode='constant',
|
|
1193
|
+
cval=1.0,
|
|
1194
|
+
)
|
|
1195
|
+
> 0.99
|
|
1196
|
+
)
|
|
1197
|
+
else:
|
|
1198
|
+
valid = np.ones((os_size, os_size), dtype=bool)
|
|
1199
|
+
|
|
1200
|
+
# Normalize to unit flux
|
|
1201
|
+
total = np.sum(stamp)
|
|
1202
|
+
if not np.isfinite(total) or total <= 0:
|
|
1203
|
+
continue
|
|
1204
|
+
stamp /= total
|
|
1205
|
+
|
|
1206
|
+
stamps.append(stamp)
|
|
1207
|
+
stamp_valids.append(valid)
|
|
1208
|
+
positions_x.append(x_star)
|
|
1209
|
+
positions_y.append(y_star)
|
|
1210
|
+
stamp_fluxes.append(float(obj['flux'][i]) if 'flux' in obj.colnames else 1.0)
|
|
1211
|
+
|
|
1212
|
+
nstars = len(stamps)
|
|
1213
|
+
log('Extracted %d valid stamps for polynomial fitting' % nstars)
|
|
1214
|
+
|
|
1215
|
+
if nstars < ncoeffs:
|
|
1216
|
+
raise ValueError(
|
|
1217
|
+
"Only %d valid stamps, need at least %d for degree=%d" % (nstars, ncoeffs, degree)
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
# Build Vandermonde matrix [nstars x ncoeffs]
|
|
1221
|
+
# Same term ordering as get_supersampled_psf_stamp: i2 outer, i1 inner
|
|
1222
|
+
x_norm = (np.array(positions_x) - x0) / sx
|
|
1223
|
+
y_norm = (np.array(positions_y) - y0) / sy
|
|
1224
|
+
stamp_fluxes = np.asarray(stamp_fluxes, dtype=float)
|
|
1225
|
+
|
|
1226
|
+
V = []
|
|
1227
|
+
for i2 in range(degree + 1):
|
|
1228
|
+
for i1 in range(degree + 1 - i2):
|
|
1229
|
+
V.append(x_norm**i1 * y_norm**i2)
|
|
1230
|
+
V = np.column_stack(V)
|
|
1231
|
+
|
|
1232
|
+
# Stack stamps as [nstars, npixels]
|
|
1233
|
+
stamps_array = np.array(stamps) # [nstars, os_size, os_size]
|
|
1234
|
+
pixels = stamps_array.reshape(nstars, -1) # [nstars, npixels]
|
|
1235
|
+
valid_array = np.array(stamp_valids) # [nstars, os_size, os_size]
|
|
1236
|
+
valid_pix = valid_array.reshape(nstars, -1) # [nstars, npixels]
|
|
1237
|
+
all_valid = bool(np.all(valid_pix))
|
|
1238
|
+
if not all_valid:
|
|
1239
|
+
log(
|
|
1240
|
+
'Masked pixels present in %d / %d stamps, using per-pixel fit weights'
|
|
1241
|
+
% (int(np.sum(~valid_pix.all(axis=1))), nstars)
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
# Each training stamp is normalized to unit flux, so an unweighted solve
|
|
1245
|
+
# over-emphasizes low-S/N outer pixels from fainter stars and broadens the
|
|
1246
|
+
# reconstructed wings. Weight by source flux as a proxy for stamp S/N.
|
|
1247
|
+
weights = np.ones(nstars, dtype=np.float64)
|
|
1248
|
+
valid_flux = np.isfinite(stamp_fluxes) & (stamp_fluxes > 0)
|
|
1249
|
+
if np.any(valid_flux):
|
|
1250
|
+
flux_ref = np.nanmedian(stamp_fluxes[valid_flux])
|
|
1251
|
+
if np.isfinite(flux_ref) and flux_ref > 0:
|
|
1252
|
+
weights[valid_flux] = stamp_fluxes[valid_flux] / flux_ref
|
|
1253
|
+
weights = np.clip(weights, 0.3, 5.0)
|
|
1254
|
+
log(
|
|
1255
|
+
'Applying flux weights to polynomial PSF fit: median %.0f, range %.2f..%.2f'
|
|
1256
|
+
% (flux_ref, np.min(weights), np.max(weights))
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
# Solve per-pixel polynomial with iterative sigma-clipping.
|
|
1260
|
+
# Outlier stamps (from neighbor contamination or artifacts) bias
|
|
1261
|
+
# the mean-based least-squares fit; clipping rejects them.
|
|
1262
|
+
keep = np.ones(nstars, dtype=bool)
|
|
1263
|
+
clip_sigma = 3.0
|
|
1264
|
+
max_clip_iters = 3
|
|
1265
|
+
|
|
1266
|
+
with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
|
|
1267
|
+
for clip_iter in range(max_clip_iters + 1):
|
|
1268
|
+
n_keep = int(keep.sum())
|
|
1269
|
+
|
|
1270
|
+
if n_keep < ncoeffs:
|
|
1271
|
+
break
|
|
1272
|
+
|
|
1273
|
+
if all_valid:
|
|
1274
|
+
w_k = weights[keep]
|
|
1275
|
+
V_k = V[keep] * w_k[:, np.newaxis]
|
|
1276
|
+
pixels_k = pixels[keep] * w_k[:, np.newaxis]
|
|
1277
|
+
|
|
1278
|
+
if regularization > 0:
|
|
1279
|
+
VTV = V_k.T @ V_k + regularization * np.eye(ncoeffs)
|
|
1280
|
+
VTp = V_k.T @ pixels_k
|
|
1281
|
+
coeffs = np.linalg.solve(VTV, VTp)
|
|
1282
|
+
else:
|
|
1283
|
+
coeffs, _, _, _ = np.linalg.lstsq(V_k, pixels_k, rcond=None)
|
|
1284
|
+
else:
|
|
1285
|
+
# Per-pixel normal equations excluding masked stamp pixels:
|
|
1286
|
+
# each output pixel gets its own (ncoeffs x ncoeffs) system
|
|
1287
|
+
# built only from the stamps valid at that pixel
|
|
1288
|
+
Wm = (weights[keep] ** 2)[:, np.newaxis] * valid_pix[keep] # (n, npix)
|
|
1289
|
+
VTV = np.einsum('sp,sk,sl->pkl', Wm, V[keep], V[keep], optimize=True)
|
|
1290
|
+
VTp = np.einsum('sp,sk->pk', Wm * pixels[keep], V[keep], optimize=True)
|
|
1291
|
+
VTV += max(regularization, 0.0) * np.eye(ncoeffs)[np.newaxis]
|
|
1292
|
+
try:
|
|
1293
|
+
coeffs = np.linalg.solve(VTV, VTp[..., np.newaxis])[..., 0].T
|
|
1294
|
+
except np.linalg.LinAlgError:
|
|
1295
|
+
# Pixels with too few valid stamps yield singular systems
|
|
1296
|
+
coeffs = np.einsum(
|
|
1297
|
+
'pkl,pl->pk', np.linalg.pinv(VTV), VTp, optimize=True
|
|
1298
|
+
).T
|
|
1299
|
+
|
|
1300
|
+
if clip_iter == max_clip_iters:
|
|
1301
|
+
break
|
|
1302
|
+
|
|
1303
|
+
# Compute per-stamp RMS residual over valid pixels only
|
|
1304
|
+
reconstructed = V @ coeffs # all stamps, not just kept
|
|
1305
|
+
res2 = (pixels - reconstructed) ** 2
|
|
1306
|
+
if all_valid:
|
|
1307
|
+
stamp_rms = np.sqrt(np.mean(res2, axis=1))
|
|
1308
|
+
else:
|
|
1309
|
+
nvalid = np.maximum(valid_pix.sum(axis=1), 1)
|
|
1310
|
+
stamp_rms = np.sqrt(np.sum(res2 * valid_pix, axis=1) / nvalid)
|
|
1311
|
+
|
|
1312
|
+
med_rms = np.median(stamp_rms[keep])
|
|
1313
|
+
mad_rms = np.median(np.abs(stamp_rms[keep] - med_rms)) * 1.4826
|
|
1314
|
+
if mad_rms < 1e-15:
|
|
1315
|
+
break
|
|
1316
|
+
|
|
1317
|
+
new_keep = stamp_rms < med_rms + clip_sigma * mad_rms
|
|
1318
|
+
n_rejected = int(keep.sum() - new_keep.sum())
|
|
1319
|
+
if n_rejected == 0:
|
|
1320
|
+
break
|
|
1321
|
+
if new_keep.sum() < ncoeffs:
|
|
1322
|
+
break
|
|
1323
|
+
|
|
1324
|
+
keep = new_keep
|
|
1325
|
+
log(
|
|
1326
|
+
'Sigma-clip iter %d: rejected %d stamps, %d remaining'
|
|
1327
|
+
% (clip_iter + 1, n_rejected, keep.sum())
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
# Suppress low-S/N outer support where the polynomial fit otherwise tends
|
|
1331
|
+
# to create square-edge pedestals and ringing.
|
|
1332
|
+
ref_stamps = (
|
|
1333
|
+
stamps_array if all_valid else np.where(valid_array, stamps_array, np.nan)
|
|
1334
|
+
)
|
|
1335
|
+
psf_data, taper_start, taper_width = _regularize_polynomial_psf(
|
|
1336
|
+
coeffs.reshape(ncoeffs, os_size, os_size),
|
|
1337
|
+
ref_stamps,
|
|
1338
|
+
keep,
|
|
1339
|
+
sampling,
|
|
1340
|
+
fwhm,
|
|
1341
|
+
)
|
|
1342
|
+
coeffs = psf_data.reshape(ncoeffs, -1)
|
|
1343
|
+
if taper_width > 0:
|
|
1344
|
+
log(
|
|
1345
|
+
'Applied outer taper to polynomial PSF: start %.2f px, width %.2f px'
|
|
1346
|
+
% (taper_start, taper_width)
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
# Compute and report residual statistics
|
|
1350
|
+
with np.errstate(over='ignore', invalid='ignore', divide='ignore'):
|
|
1351
|
+
reconstructed = V[keep] @ coeffs
|
|
1352
|
+
res2 = (pixels[keep] - reconstructed) ** 2
|
|
1353
|
+
if all_valid:
|
|
1354
|
+
rms = np.sqrt(np.nanmean(res2))
|
|
1355
|
+
else:
|
|
1356
|
+
vk = valid_pix[keep]
|
|
1357
|
+
rms = np.sqrt(np.nansum(res2 * vk) / max(int(vk.sum()), 1))
|
|
1358
|
+
log(
|
|
1359
|
+
'Polynomial PSF fit: %d x %d pixels, %d coefficients, '
|
|
1360
|
+
'%d/%d stamps used, RMS residual %.2e (per pixel, normalized)'
|
|
1361
|
+
% (os_size, os_size, ncoeffs, int(keep.sum()), nstars, rms)
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
psf = {
|
|
1365
|
+
'width': os_size,
|
|
1366
|
+
'height': os_size,
|
|
1367
|
+
'fwhm': fwhm,
|
|
1368
|
+
'sampling': sampling,
|
|
1369
|
+
'ncoeffs': ncoeffs,
|
|
1370
|
+
'degree': degree,
|
|
1371
|
+
'x0': x0,
|
|
1372
|
+
'y0': y0,
|
|
1373
|
+
'sx': sx,
|
|
1374
|
+
'sy': sy,
|
|
1375
|
+
'data': psf_data,
|
|
1376
|
+
'oversampling': oversampling,
|
|
1377
|
+
'type': 'epsf',
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
return psf
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def enclosed_psf_fraction(psf, x=0, y=0, radius=None, subpixel=10):
|
|
1384
|
+
"""Fraction of normalised PSF flux inside a circular aperture.
|
|
1385
|
+
|
|
1386
|
+
Evaluates the (possibly position-dependent) PSF model at ``(x, y)``
|
|
1387
|
+
via :func:`get_psf_stamp`, normalises it to unit total flux, and sums
|
|
1388
|
+
the pixels lying within ``radius`` of the stamp centre. Useful for
|
|
1389
|
+
on-the-fly aperture corrections.
|
|
1390
|
+
|
|
1391
|
+
Pixels are weighted by the fractional coverage of the circular
|
|
1392
|
+
aperture using a sub-pixel grid (``subpixel`` × ``subpixel`` samples
|
|
1393
|
+
per pixel) — set ``subpixel=1`` to fall back to a hard pixel-centre
|
|
1394
|
+
mask, which is faster but has up to ~10 % radius-dependent jitter
|
|
1395
|
+
when the aperture edge cuts through individual pixels.
|
|
1396
|
+
|
|
1397
|
+
Parameters
|
|
1398
|
+
----------
|
|
1399
|
+
psf : dict
|
|
1400
|
+
PSF model from :func:`run_psfex`, :func:`load_psf`, or
|
|
1401
|
+
:func:`create_psf_model`.
|
|
1402
|
+
x, y : float, optional
|
|
1403
|
+
Position in image pixels at which to evaluate a position-dependent
|
|
1404
|
+
PSF model. The sub-pixel parts of ``x`` and ``y`` are folded into
|
|
1405
|
+
the stamp centre via :func:`get_psf_stamp`.
|
|
1406
|
+
radius : float or array-like
|
|
1407
|
+
Aperture radius (or radii) in image pixels.
|
|
1408
|
+
subpixel : int, optional
|
|
1409
|
+
Linear sub-pixel sampling per image pixel for partial-coverage
|
|
1410
|
+
weighting. Default 10 (100 sub-samples per pixel).
|
|
1411
|
+
|
|
1412
|
+
Returns
|
|
1413
|
+
-------
|
|
1414
|
+
float or ndarray
|
|
1415
|
+
Enclosed flux fraction at each radius. Scalar when ``radius`` is
|
|
1416
|
+
a scalar, otherwise an array of the same shape.
|
|
1417
|
+
"""
|
|
1418
|
+
if radius is None:
|
|
1419
|
+
raise TypeError("radius is required")
|
|
1420
|
+
radius_arr = np.atleast_1d(np.asarray(radius, float))
|
|
1421
|
+
|
|
1422
|
+
stamp = get_psf_stamp(psf, x, y, normalize=True)
|
|
1423
|
+
h, w = stamp.shape
|
|
1424
|
+
cx = w // 2 + (x - np.round(x))
|
|
1425
|
+
cy = h // 2 + (y - np.round(y))
|
|
1426
|
+
|
|
1427
|
+
if int(subpixel) <= 1:
|
|
1428
|
+
yy, xx = np.mgrid[0:h, 0:w]
|
|
1429
|
+
rr2 = (xx - cx) ** 2 + (yy - cy) ** 2
|
|
1430
|
+
out = np.array([float(np.sum(stamp[rr2 <= r ** 2])) for r in radius_arr])
|
|
1431
|
+
else:
|
|
1432
|
+
n = int(subpixel)
|
|
1433
|
+
# Sub-pixel grid of offsets within a single pixel, centred on 0.
|
|
1434
|
+
sub = (np.arange(n) + 0.5) / n - 0.5
|
|
1435
|
+
dyy, dxx = np.meshgrid(sub, sub, indexing='ij')
|
|
1436
|
+
yy, xx = np.mgrid[0:h, 0:w]
|
|
1437
|
+
# For each pixel, distance² from aperture centre at every sub-pixel
|
|
1438
|
+
rr2 = (xx[:, :, None, None] - cx + dxx[None, None, :, :]) ** 2 \
|
|
1439
|
+
+ (yy[:, :, None, None] - cy + dyy[None, None, :, :]) ** 2
|
|
1440
|
+
out = np.empty(radius_arr.size, dtype=float)
|
|
1441
|
+
for i, r in enumerate(radius_arr):
|
|
1442
|
+
frac = np.mean(rr2 <= r ** 2, axis=(2, 3))
|
|
1443
|
+
out[i] = float(np.sum(stamp * frac))
|
|
1444
|
+
|
|
1445
|
+
return float(out[0]) if np.isscalar(radius) or np.ndim(radius) == 0 else out
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def select_psf_seeds(
|
|
1449
|
+
obj,
|
|
1450
|
+
image_shape,
|
|
1451
|
+
*,
|
|
1452
|
+
max_per_tile=25,
|
|
1453
|
+
grid=6,
|
|
1454
|
+
edge=20,
|
|
1455
|
+
obj_col_x='x',
|
|
1456
|
+
obj_col_y='y',
|
|
1457
|
+
obj_col_flux='flux',
|
|
1458
|
+
obj_col_flags='flags',
|
|
1459
|
+
accept_flags=0,
|
|
1460
|
+
):
|
|
1461
|
+
"""Select bright, edge-clear sources spread uniformly across the field.
|
|
1462
|
+
|
|
1463
|
+
Bins ``obj`` on a regular ``grid_x × grid_y`` spatial grid and returns
|
|
1464
|
+
the ``max_per_tile`` brightest sources per cell that pass basic
|
|
1465
|
+
quality filters: finite ``x``/``y``/flux, positive flux, optionally a
|
|
1466
|
+
flag mask, and inside the image after an ``edge``-pixel margin.
|
|
1467
|
+
|
|
1468
|
+
The returned table is a strict subset of ``obj`` (same columns,
|
|
1469
|
+
sub-selected rows) and is the natural input for
|
|
1470
|
+
:func:`stdpipe.psf.create_psf_model` and similar PSF-modelling
|
|
1471
|
+
routines that benefit from a uniform spatial sampling.
|
|
1472
|
+
|
|
1473
|
+
Parameters
|
|
1474
|
+
----------
|
|
1475
|
+
obj : astropy.table.Table
|
|
1476
|
+
Source catalogue.
|
|
1477
|
+
image_shape : (H, W) tuple
|
|
1478
|
+
Image shape used for the spatial grid and edge mask.
|
|
1479
|
+
max_per_tile : int
|
|
1480
|
+
Maximum number of seeds kept per spatial cell.
|
|
1481
|
+
grid : int or (nx, ny) tuple
|
|
1482
|
+
Number of cells in the spatial grid. A scalar means a square
|
|
1483
|
+
``grid × grid`` layout.
|
|
1484
|
+
edge : float
|
|
1485
|
+
Edge margin in pixels; sources within ``edge`` of any image
|
|
1486
|
+
boundary are excluded.
|
|
1487
|
+
obj_col_x, obj_col_y : str
|
|
1488
|
+
Column names for source positions in pixel coordinates.
|
|
1489
|
+
obj_col_flux : str
|
|
1490
|
+
Column name used to rank candidates within each cell (brightest
|
|
1491
|
+
first). Sources with non-positive values are dropped.
|
|
1492
|
+
obj_col_flags : str, optional
|
|
1493
|
+
Column name for a SExtractor-style integer flag mask. Set to
|
|
1494
|
+
``None`` to skip flag filtering. If the column is missing from
|
|
1495
|
+
``obj``, no flag filter is applied either.
|
|
1496
|
+
accept_flags : int
|
|
1497
|
+
Bitwise mask of acceptable flags. A source is kept only when
|
|
1498
|
+
``flags & ~accept_flags == 0``.
|
|
1499
|
+
|
|
1500
|
+
Returns
|
|
1501
|
+
-------
|
|
1502
|
+
astropy.table.Table
|
|
1503
|
+
A subset of ``obj`` containing the selected seeds.
|
|
1504
|
+
"""
|
|
1505
|
+
if isinstance(grid, int):
|
|
1506
|
+
grid_x = grid_y = grid
|
|
1507
|
+
else:
|
|
1508
|
+
grid_x, grid_y = grid
|
|
1509
|
+
|
|
1510
|
+
H, W = image_shape
|
|
1511
|
+
|
|
1512
|
+
x = np.asarray(obj[obj_col_x], float)
|
|
1513
|
+
y = np.asarray(obj[obj_col_y], float)
|
|
1514
|
+
flux = np.asarray(obj[obj_col_flux], float)
|
|
1515
|
+
good = (
|
|
1516
|
+
np.isfinite(x) & np.isfinite(y) & np.isfinite(flux) & (flux > 0)
|
|
1517
|
+
& (x > edge) & (x < W - edge) & (y > edge) & (y < H - edge)
|
|
1518
|
+
)
|
|
1519
|
+
if obj_col_flags is not None and obj_col_flags in obj.colnames:
|
|
1520
|
+
flags = np.asarray(obj[obj_col_flags], int)
|
|
1521
|
+
good &= (flags & ~int(accept_flags)) == 0
|
|
1522
|
+
|
|
1523
|
+
if not np.any(good):
|
|
1524
|
+
return obj[good] # empty subset, preserves columns
|
|
1525
|
+
|
|
1526
|
+
cand_idx = np.where(good)[0]
|
|
1527
|
+
cx = x[cand_idx]; cy = y[cand_idx]; cflux = flux[cand_idx]
|
|
1528
|
+
|
|
1529
|
+
xbin = np.clip((cx / W * grid_x).astype(int), 0, grid_x - 1)
|
|
1530
|
+
ybin = np.clip((cy / H * grid_y).astype(int), 0, grid_y - 1)
|
|
1531
|
+
|
|
1532
|
+
keep = []
|
|
1533
|
+
for iy in range(grid_y):
|
|
1534
|
+
for ix in range(grid_x):
|
|
1535
|
+
sel = np.where((xbin == ix) & (ybin == iy))[0]
|
|
1536
|
+
if sel.size:
|
|
1537
|
+
order = np.argsort(cflux[sel])[::-1][:max_per_tile]
|
|
1538
|
+
keep.append(cand_idx[sel[order]])
|
|
1539
|
+
|
|
1540
|
+
if not keep:
|
|
1541
|
+
return obj[np.zeros(len(obj), dtype=bool)]
|
|
1542
|
+
return obj[np.unique(np.concatenate(keep))]
|