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/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))]