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/pipeline.py
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module containing higher-level pipeline building blocks, wrapping together lower-level
|
|
3
|
+
functionality of STDPipe modules.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import numpy as np
|
|
8
|
+
import itertools
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
|
|
11
|
+
from astropy.wcs import WCS
|
|
12
|
+
from astropy.io import fits
|
|
13
|
+
from astropy.coordinates import SkyCoord
|
|
14
|
+
from astropy.time import Time
|
|
15
|
+
from astropy.table import Table
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import sep_x as sep
|
|
19
|
+
except ImportError:
|
|
20
|
+
import sep
|
|
21
|
+
import astroscrappy
|
|
22
|
+
|
|
23
|
+
from . import photometry
|
|
24
|
+
from . import astrometry
|
|
25
|
+
from . import catalogs
|
|
26
|
+
from . import psf
|
|
27
|
+
from . import utils
|
|
28
|
+
from . import cutouts
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_mask(
|
|
32
|
+
image,
|
|
33
|
+
header=None,
|
|
34
|
+
saturation=None,
|
|
35
|
+
external_mask=None,
|
|
36
|
+
mask_cosmics=False,
|
|
37
|
+
gain=None,
|
|
38
|
+
verbose=True,
|
|
39
|
+
):
|
|
40
|
+
"""Make a basic mask for an image.
|
|
41
|
+
|
|
42
|
+
The mask is a boolean bitmap with ``True`` values marking regions to be
|
|
43
|
+
excluded from further processing. The following regions are masked:
|
|
44
|
+
|
|
45
|
+
- pixels with undefined or non-finite (inf or nan) values
|
|
46
|
+
- regions outside the usable area defined by the ``DATASEC`` or ``TRIMSEC``
|
|
47
|
+
header keyword
|
|
48
|
+
- pixels above the saturation limit, if provided
|
|
49
|
+
- pixels set in the external mask, if provided
|
|
50
|
+
- cosmic rays, if requested
|
|
51
|
+
|
|
52
|
+
If ``saturation=True``, the saturation level is estimated from the image as
|
|
53
|
+
``median + 0.95 * (max - median)``.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
image : ndarray
|
|
58
|
+
2D image array to mask.
|
|
59
|
+
header : astropy.io.fits.Header, optional
|
|
60
|
+
FITS header; used to read ``DATASEC`` / ``TRIMSEC`` keywords.
|
|
61
|
+
saturation : float or bool, optional
|
|
62
|
+
Saturation level in ADU. If ``True``, estimated automatically from
|
|
63
|
+
the image.
|
|
64
|
+
external_mask : ndarray, optional
|
|
65
|
+
Boolean mask to OR with the created mask.
|
|
66
|
+
mask_cosmics : bool, optional
|
|
67
|
+
If True, detect and mask cosmic rays using the LACosmic algorithm.
|
|
68
|
+
gain : float, optional
|
|
69
|
+
Detector gain in e-/ADU, used for cosmic-ray masking.
|
|
70
|
+
verbose : bool or callable, optional
|
|
71
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
ndarray of bool
|
|
76
|
+
Boolean mask with ``True`` marking excluded pixels.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
80
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
81
|
+
|
|
82
|
+
mask = ~np.isfinite(image)
|
|
83
|
+
|
|
84
|
+
if header is not None:
|
|
85
|
+
# DATASEC or TRIMSEC
|
|
86
|
+
for kw in ['TRIMSEC', 'DATASEC']:
|
|
87
|
+
if kw in header:
|
|
88
|
+
x1, x2, y1, y2 = utils.parse_det(header.get(kw))
|
|
89
|
+
|
|
90
|
+
log('Masking the region outside of %s = %s' % (kw, header.get(kw)))
|
|
91
|
+
mask1 = np.ones_like(mask)
|
|
92
|
+
mask1[y1 : y2 + 1, x1 : x2 + 1] = False
|
|
93
|
+
mask |= mask1
|
|
94
|
+
|
|
95
|
+
if saturation is not None:
|
|
96
|
+
if type(saturation) is bool and saturation == True:
|
|
97
|
+
saturation = 0.05 * np.nanmedian(image) + 0.95 * np.nanmax(image) # med + 0.95(max-med)
|
|
98
|
+
log('Masking pixels above saturation level %.1f ADU' % saturation)
|
|
99
|
+
mask |= image >= saturation
|
|
100
|
+
|
|
101
|
+
if external_mask is not None:
|
|
102
|
+
external_mask = external_mask.astype(bool)
|
|
103
|
+
log('Applying external mask with %d masked pixels' % np.sum(external_mask))
|
|
104
|
+
mask |= external_mask
|
|
105
|
+
|
|
106
|
+
if mask_cosmics:
|
|
107
|
+
# We will use custom noise model for astroscrappy as we do not know whether
|
|
108
|
+
# the image is background-subtracted already, or how it was flatfielded
|
|
109
|
+
bg = sep.Background(image, mask=mask)
|
|
110
|
+
rms = bg.rms()
|
|
111
|
+
var = rms**2
|
|
112
|
+
if gain:
|
|
113
|
+
var += np.abs(image - bg.back()) / gain
|
|
114
|
+
cmask, cimage = astroscrappy.detect_cosmics(
|
|
115
|
+
image,
|
|
116
|
+
mask,
|
|
117
|
+
verbose=verbose,
|
|
118
|
+
invar=var.astype(np.float32),
|
|
119
|
+
gain=gain if gain else 1.0,
|
|
120
|
+
satlevel=saturation if saturation else np.max(image),
|
|
121
|
+
cleantype='medmask',
|
|
122
|
+
)
|
|
123
|
+
log(
|
|
124
|
+
'Done masking cosmics, %d (%.1f%%) pixels masked'
|
|
125
|
+
% (np.sum(cmask), 100 * np.sum(cmask) / cmask.shape[0] / cmask.shape[1])
|
|
126
|
+
)
|
|
127
|
+
mask |= cmask
|
|
128
|
+
|
|
129
|
+
log(
|
|
130
|
+
'%d (%.1f%%) pixels masked in total'
|
|
131
|
+
% (np.sum(mask), 100 * np.sum(mask) / mask.shape[0] / mask.shape[1])
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return mask
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def refine_astrometry(
|
|
138
|
+
obj,
|
|
139
|
+
cat,
|
|
140
|
+
sr=10 / 3600,
|
|
141
|
+
wcs=None,
|
|
142
|
+
order=0,
|
|
143
|
+
cat_col_mag='V',
|
|
144
|
+
cat_col_mag_err=None,
|
|
145
|
+
cat_col_ra='RAJ2000',
|
|
146
|
+
cat_col_dec='DEJ2000',
|
|
147
|
+
cat_col_ra_err='e_RAJ2000',
|
|
148
|
+
cat_col_dec_err='e_DEJ2000',
|
|
149
|
+
n_iter=3,
|
|
150
|
+
use_photometry=True,
|
|
151
|
+
min_matches=5,
|
|
152
|
+
method='quadhash',
|
|
153
|
+
update=True,
|
|
154
|
+
refine_residual_field=False,
|
|
155
|
+
residual_field_kwargs=None,
|
|
156
|
+
verbose=False,
|
|
157
|
+
**kwargs,
|
|
158
|
+
):
|
|
159
|
+
"""Higher-level astrometric refinement routine.
|
|
160
|
+
|
|
161
|
+
Dispatches to quad-hash, SCAMP, astropy, or astrometrynet fitting
|
|
162
|
+
depending on ``method``.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
obj : astropy.table.Table
|
|
167
|
+
List of objects on the frame, must contain at least ``x``, ``y``, and ``flux`` columns.
|
|
168
|
+
cat : astropy.table.Table or str
|
|
169
|
+
Reference astrometric catalogue.
|
|
170
|
+
sr : float, optional
|
|
171
|
+
Matching radius in degrees.
|
|
172
|
+
wcs : astropy.wcs.WCS, optional
|
|
173
|
+
Initial WCS solution.
|
|
174
|
+
order : int or str, optional
|
|
175
|
+
Polynomial order for SIP or PV distortion solution. Default 0 for TAN;
|
|
176
|
+
2 is recommended for ``quadhash``. With ``method='scamp'`` it may also be
|
|
177
|
+
``'auto'`` to select the degree from hold-out residuals.
|
|
178
|
+
cat_col_mag : str, optional
|
|
179
|
+
Catalogue column name for magnitude.
|
|
180
|
+
cat_col_mag_err : str, optional
|
|
181
|
+
Catalogue column name for magnitude error.
|
|
182
|
+
cat_col_ra : str, optional
|
|
183
|
+
Catalogue column name for Right Ascension.
|
|
184
|
+
cat_col_dec : str, optional
|
|
185
|
+
Catalogue column name for Declination.
|
|
186
|
+
cat_col_ra_err : str, optional
|
|
187
|
+
Catalogue column name for Right Ascension error.
|
|
188
|
+
cat_col_dec_err : str, optional
|
|
189
|
+
Catalogue column name for Declination error.
|
|
190
|
+
n_iter : int, optional
|
|
191
|
+
Number of iterations for Python-based matching.
|
|
192
|
+
use_photometry : bool, optional
|
|
193
|
+
Use photometry-assisted matching in Python-based methods.
|
|
194
|
+
min_matches : int, optional
|
|
195
|
+
Minimum number of good matches required.
|
|
196
|
+
method : str, optional
|
|
197
|
+
Fitting method. One of ``'quadhash'`` (default, 2–7× more accurate),
|
|
198
|
+
``'scamp'``, ``'astropy'``, or ``'astrometrynet'``.
|
|
199
|
+
update : bool, optional
|
|
200
|
+
If True, update ``ra`` and ``dec`` columns in ``obj`` in-place.
|
|
201
|
+
When combined with ``refine_residual_field=True``, ``x``/``y`` of
|
|
202
|
+
``obj`` are also corrected (their residual against the catalogue
|
|
203
|
+
is subtracted) before ``ra``/``dec`` are recomputed from the
|
|
204
|
+
refined positions.
|
|
205
|
+
refine_residual_field : bool, optional
|
|
206
|
+
If True, after the WCS fit, fit a smooth (dx, dy) correction
|
|
207
|
+
field on the matched ``obj``--``cat`` residuals using
|
|
208
|
+
:func:`stdpipe.astrometry.refine_positions_from_catalog` and (if
|
|
209
|
+
``update=True``) apply the inverse correction to ``obj['x']``,
|
|
210
|
+
``obj['y']`` in place.
|
|
211
|
+
residual_field_kwargs : dict, optional
|
|
212
|
+
Forwarded to :func:`refine_positions_from_catalog`. Common keys:
|
|
213
|
+
``backend``, ``image_shape``, ``grid_shape``, ``min_per_cell``,
|
|
214
|
+
``smooth_sigma``.
|
|
215
|
+
verbose : bool or callable, optional
|
|
216
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
217
|
+
**kwargs
|
|
218
|
+
All other keyword arguments are passed to the respective refinement function.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
astropy.wcs.WCS or None
|
|
223
|
+
Refined astrometric solution, or None on failure.
|
|
224
|
+
"""
|
|
225
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
226
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
227
|
+
|
|
228
|
+
log(
|
|
229
|
+
'Astrometric refinement using %.1f arcsec radius, %s matching and %s WCS fitting'
|
|
230
|
+
% (sr * 3600, 'photometric' if use_photometry else 'simple positional', method)
|
|
231
|
+
)
|
|
232
|
+
if type(cat) == str:
|
|
233
|
+
log('Using %d objects and catalogue %s' % (len(obj), cat))
|
|
234
|
+
else:
|
|
235
|
+
log('Using %d objects and %d catalogue stars' % (len(obj), len(cat)))
|
|
236
|
+
|
|
237
|
+
if wcs is not None:
|
|
238
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
239
|
+
|
|
240
|
+
if method == 'quadhash':
|
|
241
|
+
# Quad-hash pattern matching (default, hopefully more accurate than SCAMP)
|
|
242
|
+
|
|
243
|
+
wcs = astrometry.refine_wcs_quadhash(
|
|
244
|
+
obj,
|
|
245
|
+
cat,
|
|
246
|
+
wcs=wcs,
|
|
247
|
+
sr=sr,
|
|
248
|
+
order=order,
|
|
249
|
+
cat_col_ra=cat_col_ra,
|
|
250
|
+
cat_col_dec=cat_col_dec,
|
|
251
|
+
cat_col_mag=cat_col_mag,
|
|
252
|
+
update=update,
|
|
253
|
+
verbose=verbose,
|
|
254
|
+
**kwargs,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
elif method == 'scamp':
|
|
258
|
+
# Fall-through to SCAMP-specific variant
|
|
259
|
+
wcs = astrometry.refine_wcs_scamp(
|
|
260
|
+
obj,
|
|
261
|
+
cat,
|
|
262
|
+
sr=sr,
|
|
263
|
+
wcs=wcs,
|
|
264
|
+
order=order,
|
|
265
|
+
cat_col_mag=cat_col_mag,
|
|
266
|
+
cat_col_mag_err=cat_col_mag_err,
|
|
267
|
+
cat_col_ra=cat_col_ra,
|
|
268
|
+
cat_col_dec=cat_col_dec,
|
|
269
|
+
cat_col_ra_err=cat_col_ra_err,
|
|
270
|
+
cat_col_dec_err=cat_col_dec_err,
|
|
271
|
+
update=update,
|
|
272
|
+
verbose=verbose,
|
|
273
|
+
**kwargs,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
else:
|
|
277
|
+
wcs = _refine_astrometry_iterative(
|
|
278
|
+
obj, cat, sr=sr, wcs=wcs, order=order,
|
|
279
|
+
cat_col_mag=cat_col_mag, cat_col_mag_err=cat_col_mag_err,
|
|
280
|
+
cat_col_ra=cat_col_ra, cat_col_dec=cat_col_dec,
|
|
281
|
+
n_iter=n_iter, use_photometry=use_photometry,
|
|
282
|
+
min_matches=min_matches, method=method,
|
|
283
|
+
update=update, log=log, verbose=verbose, **kwargs,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if refine_residual_field and wcs is not None:
|
|
287
|
+
_apply_residual_field_correction(
|
|
288
|
+
obj, cat, wcs, sr=sr,
|
|
289
|
+
cat_col_ra=cat_col_ra, cat_col_dec=cat_col_dec,
|
|
290
|
+
update=update,
|
|
291
|
+
residual_field_kwargs=residual_field_kwargs or {},
|
|
292
|
+
log=log,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return wcs
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _apply_residual_field_correction(
|
|
299
|
+
obj, cat, wcs, *, sr,
|
|
300
|
+
cat_col_ra, cat_col_dec,
|
|
301
|
+
update, residual_field_kwargs, log,
|
|
302
|
+
):
|
|
303
|
+
"""Re-match obj↔cat positionally, fit a smooth residual field, and
|
|
304
|
+
optionally apply the inverse correction to ``obj['x']``, ``obj['y']``
|
|
305
|
+
in place. Helper for :func:`refine_astrometry`."""
|
|
306
|
+
obj_x = np.asarray(obj['x'], float)
|
|
307
|
+
obj_y = np.asarray(obj['y'], float)
|
|
308
|
+
obj_ra, obj_dec = wcs.all_pix2world(obj_x, obj_y, 0)
|
|
309
|
+
cat_ra = np.asarray(cat[cat_col_ra], float)
|
|
310
|
+
cat_dec = np.asarray(cat[cat_col_dec], float)
|
|
311
|
+
|
|
312
|
+
# All obj↔cat pairs within sr (degrees). spherical_match returns every
|
|
313
|
+
# pair, so dedupe to one nearest catalogue match per object.
|
|
314
|
+
oidx_all, cidx_all, dist_all = astrometry.spherical_match(
|
|
315
|
+
obj_ra, obj_dec, cat_ra, cat_dec, sr=sr,
|
|
316
|
+
)
|
|
317
|
+
if len(oidx_all) < 50:
|
|
318
|
+
log('Residual field skipped: only %d positional matches' % len(oidx_all))
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
order = np.argsort(dist_all)
|
|
322
|
+
oidx_sorted = oidx_all[order]
|
|
323
|
+
cidx_sorted = cidx_all[order]
|
|
324
|
+
_, first = np.unique(oidx_sorted, return_index=True)
|
|
325
|
+
oidx_unique = oidx_sorted[first]
|
|
326
|
+
cidx_unique = cidx_sorted[first]
|
|
327
|
+
|
|
328
|
+
if len(oidx_unique) < 50:
|
|
329
|
+
log('Residual field skipped: only %d unique positional matches'
|
|
330
|
+
% len(oidx_unique))
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
accepted = np.zeros(len(obj), dtype=bool)
|
|
334
|
+
accepted[oidx_unique] = True
|
|
335
|
+
cidx_aligned = np.full(len(obj), -1, dtype=int)
|
|
336
|
+
cidx_aligned[oidx_unique] = cidx_unique
|
|
337
|
+
match = {
|
|
338
|
+
'idx': accepted,
|
|
339
|
+
'oidx': np.arange(len(obj)),
|
|
340
|
+
'cidx': cidx_aligned,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
correct, info = astrometry.refine_positions_from_catalog(
|
|
344
|
+
match, obj, cat, wcs,
|
|
345
|
+
cat_col_ra=cat_col_ra, cat_col_dec=cat_col_dec,
|
|
346
|
+
**residual_field_kwargs,
|
|
347
|
+
)
|
|
348
|
+
log(
|
|
349
|
+
'Residual field on %d matches: median |Δ| %.3f → %.3f px, '
|
|
350
|
+
'q90 %.3f → %.3f px' % (
|
|
351
|
+
info['n_used'],
|
|
352
|
+
info['raw_median_dr_pix'], info['corrected_median_dr_pix'],
|
|
353
|
+
info['raw_q90_dr_pix'], info['corrected_q90_dr_pix'],
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if update:
|
|
358
|
+
# Apply the inverse correction at observed positions: each
|
|
359
|
+
# detection is shifted by -(dx, dy) so that the corrected x/y
|
|
360
|
+
# lie on the WCS-consistent grid. The residual field was fit at
|
|
361
|
+
# predicted positions, but the typical residual is ≪ FWHM so
|
|
362
|
+
# evaluating at observed positions is fine.
|
|
363
|
+
cdx, cdy = correct.smoother(obj_x, obj_y)
|
|
364
|
+
obj['x'] = obj_x - cdx
|
|
365
|
+
obj['y'] = obj_y - cdy
|
|
366
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _refine_astrometry_iterative(
|
|
370
|
+
obj, cat, *, sr, wcs, order,
|
|
371
|
+
cat_col_mag, cat_col_mag_err, cat_col_ra, cat_col_dec,
|
|
372
|
+
n_iter, use_photometry, min_matches, method, update, log, verbose, **kwargs,
|
|
373
|
+
):
|
|
374
|
+
"""Iterative Python/astrometrynet refinement loop, factored out of
|
|
375
|
+
:func:`refine_astrometry` so the dispatch path can be linear."""
|
|
376
|
+
for iter in range(n_iter):
|
|
377
|
+
if use_photometry:
|
|
378
|
+
# Matching involving photometric information
|
|
379
|
+
cat_magerr = cat[cat_col_mag_err] if cat_col_mag_err is not None else None
|
|
380
|
+
m = photometry.match(
|
|
381
|
+
obj['ra'],
|
|
382
|
+
obj['dec'],
|
|
383
|
+
obj['mag'],
|
|
384
|
+
obj['magerr'],
|
|
385
|
+
obj['flags'],
|
|
386
|
+
cat[cat_col_ra],
|
|
387
|
+
cat[cat_col_dec],
|
|
388
|
+
cat[cat_col_mag],
|
|
389
|
+
cat_magerr=cat_magerr,
|
|
390
|
+
sr=sr,
|
|
391
|
+
verbose=verbose,
|
|
392
|
+
)
|
|
393
|
+
if m is None or not m:
|
|
394
|
+
log('Photometric match failed, cannot refine WCS')
|
|
395
|
+
return None
|
|
396
|
+
elif np.sum(m['idx']) < min_matches:
|
|
397
|
+
log('Too few (%d) good photometric matches, cannot refine WCS' % np.sum(m['idx']))
|
|
398
|
+
return None
|
|
399
|
+
else:
|
|
400
|
+
log(
|
|
401
|
+
'Iteration %d: %d matches, %.1f arcsec rms'
|
|
402
|
+
% (iter, np.sum(m['idx']), np.std(3600 * m['dist'][m['idx']]))
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
wcs = astrometry.refine_wcs_simple(
|
|
406
|
+
obj[m['oidx']][m['idx']],
|
|
407
|
+
cat[m['cidx']][m['idx']],
|
|
408
|
+
order=order,
|
|
409
|
+
match=False,
|
|
410
|
+
method=method,
|
|
411
|
+
verbose=verbose,
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
# Simple positional matching
|
|
415
|
+
wcs = astrometry.refine_wcs_simple(
|
|
416
|
+
obj, cat, order=order, sr=sr, match=True, method=method, verbose=verbose, **kwargs
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if update:
|
|
420
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
421
|
+
|
|
422
|
+
return wcs
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def filter_transient_candidates(
|
|
426
|
+
obj,
|
|
427
|
+
sr=None,
|
|
428
|
+
pixscale=None,
|
|
429
|
+
fwhm=None,
|
|
430
|
+
time=None,
|
|
431
|
+
obj_col_ra='ra',
|
|
432
|
+
obj_col_dec='dec',
|
|
433
|
+
cat=None,
|
|
434
|
+
cat_col_ra='RAJ2000',
|
|
435
|
+
cat_col_dec='DEJ2000',
|
|
436
|
+
vizier=[],
|
|
437
|
+
skybot=True,
|
|
438
|
+
ned=False,
|
|
439
|
+
flagged=True,
|
|
440
|
+
flagmask=0x7F00,
|
|
441
|
+
col_id=None,
|
|
442
|
+
vizier_checker_fn=None,
|
|
443
|
+
get_candidates=True,
|
|
444
|
+
remove=True,
|
|
445
|
+
verbose=False,
|
|
446
|
+
):
|
|
447
|
+
"""Higher-level transient candidate filtering routine.
|
|
448
|
+
|
|
449
|
+
Optionally filters out the following classes of objects:
|
|
450
|
+
|
|
451
|
+
- flagged ones (``obj['flags'] != 0``)
|
|
452
|
+
- positionally coincident with stars from a provided reference catalogue
|
|
453
|
+
- positionally coincident with stars from Vizier catalogues
|
|
454
|
+
- positionally and temporally coincident with Solar System objects from SkyBoT
|
|
455
|
+
- positionally and temporally coincident with NED objects
|
|
456
|
+
|
|
457
|
+
The original object list is never modified; a filtered or annotated copy is returned.
|
|
458
|
+
|
|
459
|
+
If ``get_candidates=False``, returns only a boolean mask of objects surviving all filters.
|
|
460
|
+
If ``get_candidates=True`` and ``remove=False``, all objects are returned but annotated
|
|
461
|
+
with ``candidate_*`` columns indicating which filters matched each object, plus a
|
|
462
|
+
``candidate_good`` column that is ``True`` for objects surviving all filters.
|
|
463
|
+
|
|
464
|
+
Parameters
|
|
465
|
+
----------
|
|
466
|
+
obj : astropy.table.Table
|
|
467
|
+
Input object list.
|
|
468
|
+
sr : float, optional
|
|
469
|
+
Matching radius in degrees. Defaults to half FWHM (if ``pixscale`` is set) or 1 arcsec.
|
|
470
|
+
pixscale : float, optional
|
|
471
|
+
Pixel scale in degrees/pixel. Used to compute the default matching radius.
|
|
472
|
+
fwhm : float, optional
|
|
473
|
+
FWHM in pixels. Estimated from unflagged objects if not provided.
|
|
474
|
+
time : astropy.time.Time or datetime.datetime, optional
|
|
475
|
+
Observation time; required for SkyBoT cross-matching.
|
|
476
|
+
obj_col_ra : str, optional
|
|
477
|
+
Column name for object Right Ascension.
|
|
478
|
+
obj_col_dec : str, optional
|
|
479
|
+
Column name for object Declination.
|
|
480
|
+
cat : astropy.table.Table, optional
|
|
481
|
+
Reference catalogue for spatial cross-matching.
|
|
482
|
+
cat_col_ra : str, optional
|
|
483
|
+
Column name for catalogue Right Ascension.
|
|
484
|
+
cat_col_dec : str, optional
|
|
485
|
+
Column name for catalogue Declination.
|
|
486
|
+
vizier : list of str, optional
|
|
487
|
+
Vizier catalogue identifiers (or short names) to cross-match against.
|
|
488
|
+
skybot : bool, optional
|
|
489
|
+
If True, cross-match with SkyBoT Solar System object positions.
|
|
490
|
+
ned : bool, optional
|
|
491
|
+
If True, cross-match with NED database entries.
|
|
492
|
+
flagged : bool, optional
|
|
493
|
+
If True, filter out objects where ``(obj['flags'] & flagmask) != 0``.
|
|
494
|
+
flagmask : int, optional
|
|
495
|
+
Bitmask applied to ``obj['flags']`` for flag filtering.
|
|
496
|
+
col_id : str, optional
|
|
497
|
+
Column name for a unique object identifier. A ``stdpipe_id`` column is
|
|
498
|
+
created automatically if not specified.
|
|
499
|
+
vizier_checker_fn : callable, optional
|
|
500
|
+
Function ``fn(obj, xcat, catname) -> bool array`` to apply additional
|
|
501
|
+
conditions on Vizier cross-matches before they are considered true matches.
|
|
502
|
+
get_candidates : bool, optional
|
|
503
|
+
If True (default), return the filtered/annotated object list.
|
|
504
|
+
If False, return only a boolean mask over the original list.
|
|
505
|
+
remove : bool, optional
|
|
506
|
+
If True (default), remove filtered entries from the returned list.
|
|
507
|
+
If False, keep all objects and add ``candidate_*`` annotation columns.
|
|
508
|
+
verbose : bool or callable, optional
|
|
509
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
510
|
+
|
|
511
|
+
Returns
|
|
512
|
+
-------
|
|
513
|
+
astropy.table.Table or ndarray of bool
|
|
514
|
+
Filtered/annotated copy of the object list, or (if ``get_candidates=False``)
|
|
515
|
+
a boolean mask of the same length as the input.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
519
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
520
|
+
|
|
521
|
+
if fwhm is None:
|
|
522
|
+
idx = obj['flags'] == 0
|
|
523
|
+
idx &= obj['magerr'] < 0.05 # S/N > 20
|
|
524
|
+
fwhm = np.median(obj['fwhm'][idx])
|
|
525
|
+
|
|
526
|
+
if sr is None:
|
|
527
|
+
if pixscale is not None:
|
|
528
|
+
# Matching radius of half FWHM
|
|
529
|
+
sr = np.median(fwhm * pixscale) / 2
|
|
530
|
+
else:
|
|
531
|
+
# Fallback value of 1 arcsec, should be sensible for most catalogues
|
|
532
|
+
sr = 1 / 3600
|
|
533
|
+
|
|
534
|
+
if col_id is None:
|
|
535
|
+
col_id = 'stdpipe_id'
|
|
536
|
+
|
|
537
|
+
if col_id not in obj.keys():
|
|
538
|
+
obj_in = obj
|
|
539
|
+
obj = obj.copy()
|
|
540
|
+
obj[col_id] = np.arange(len(obj))
|
|
541
|
+
else:
|
|
542
|
+
obj_in = obj
|
|
543
|
+
|
|
544
|
+
if remove == False:
|
|
545
|
+
# We are asked to just mark the matched candidates, not remove from the result
|
|
546
|
+
# So let's create a copy of objects list where we may freely add extra columns without touching the original
|
|
547
|
+
obj_in = obj_in.copy()
|
|
548
|
+
|
|
549
|
+
log(
|
|
550
|
+
'Candidate filtering routine started with %d initial candidates and %.1f arcsec matching radius'
|
|
551
|
+
% (len(obj), sr * 3600)
|
|
552
|
+
)
|
|
553
|
+
cand_idx = np.ones(len(obj), dtype=bool)
|
|
554
|
+
|
|
555
|
+
# Object flags
|
|
556
|
+
if flagged:
|
|
557
|
+
# Filter out flagged objects (saturated, cosmics, blends, etc)
|
|
558
|
+
idx = (obj['flags'] & flagmask) == 0
|
|
559
|
+
|
|
560
|
+
if remove == False:
|
|
561
|
+
obj_in['candidate_flagged'] = ~idx
|
|
562
|
+
|
|
563
|
+
cand_idx &= idx
|
|
564
|
+
log(np.sum(cand_idx), 'of them are unflagged')
|
|
565
|
+
|
|
566
|
+
# Reference catalogue
|
|
567
|
+
if cat is not None and remove == False:
|
|
568
|
+
obj_in['candidate_refcat'] = False
|
|
569
|
+
if cat is not None and np.any(cand_idx):
|
|
570
|
+
m = astrometry.spherical_match(
|
|
571
|
+
obj[obj_col_ra], obj[obj_col_dec], cat[cat_col_ra], cat[cat_col_dec], sr
|
|
572
|
+
)
|
|
573
|
+
cand_idx[m[0]] = False
|
|
574
|
+
|
|
575
|
+
if remove == False:
|
|
576
|
+
obj_in['candidate_refcat'] = False
|
|
577
|
+
obj_in['candidate_refcat'][m[0]] = True
|
|
578
|
+
|
|
579
|
+
log(np.sum(cand_idx), 'of them are not matched with reference catalogue')
|
|
580
|
+
|
|
581
|
+
# Vizier catalogues
|
|
582
|
+
for catname in vizier or []:
|
|
583
|
+
if remove == False:
|
|
584
|
+
if 'candidate_vizier_' + catname not in obj_in.keys():
|
|
585
|
+
obj_in['candidate_vizier_' + catname] = False
|
|
586
|
+
|
|
587
|
+
if not np.any(cand_idx):
|
|
588
|
+
break
|
|
589
|
+
|
|
590
|
+
xcat = catalogs.xmatch_objects(
|
|
591
|
+
obj[cand_idx][[col_id, obj_col_ra, obj_col_dec]],
|
|
592
|
+
catname,
|
|
593
|
+
sr,
|
|
594
|
+
col_ra=obj_col_ra,
|
|
595
|
+
col_dec=obj_col_dec,
|
|
596
|
+
)
|
|
597
|
+
if xcat is not None and len(xcat):
|
|
598
|
+
if callable(vizier_checker_fn):
|
|
599
|
+
# Pass matched results through user-supplied checker
|
|
600
|
+
xobj = obj[[np.where(obj[col_id] == _)[0][0] for _ in xcat[col_id]]]
|
|
601
|
+
xidx = vizier_checker_fn(xobj, xcat, catname)
|
|
602
|
+
xcat = xcat[xidx]
|
|
603
|
+
|
|
604
|
+
cand_idx &= ~np.in1d(obj[col_id], xcat[col_id])
|
|
605
|
+
|
|
606
|
+
if remove == False:
|
|
607
|
+
obj_in['candidate_vizier_' + catname][np.in1d(obj[col_id], xcat[col_id])] = True
|
|
608
|
+
|
|
609
|
+
log(
|
|
610
|
+
np.sum(cand_idx),
|
|
611
|
+
'remains after matching with',
|
|
612
|
+
catalogs.catalogs.get(catname, {'name': catname})['name'],
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# SkyBoT
|
|
616
|
+
if skybot and np.any(cand_idx):
|
|
617
|
+
if time is None and 'time' in obj.keys():
|
|
618
|
+
time = obj['time']
|
|
619
|
+
|
|
620
|
+
if remove == False:
|
|
621
|
+
if 'candidate_skybot' not in obj_in.keys():
|
|
622
|
+
obj_in['candidate_skybot'] = False
|
|
623
|
+
|
|
624
|
+
if time is not None:
|
|
625
|
+
xcat = catalogs.xmatch_skybot(
|
|
626
|
+
obj[cand_idx], time=time, col_ra=obj_col_ra, col_dec=obj_col_dec, col_id=col_id
|
|
627
|
+
)
|
|
628
|
+
if xcat is not None and len(xcat):
|
|
629
|
+
cand_idx &= ~np.in1d(obj[col_id], xcat[col_id])
|
|
630
|
+
|
|
631
|
+
if remove == False:
|
|
632
|
+
obj_in['candidate_skybot'][np.in1d(obj[col_id], xcat[col_id])] = True
|
|
633
|
+
|
|
634
|
+
log(np.sum(cand_idx), 'remains after matching with SkyBot')
|
|
635
|
+
|
|
636
|
+
# NED
|
|
637
|
+
if ned and np.any(cand_idx):
|
|
638
|
+
if remove == False:
|
|
639
|
+
if 'candidate_ned' not in obj_in.keys():
|
|
640
|
+
obj_in['candidate_ned'] = False
|
|
641
|
+
|
|
642
|
+
xcat = catalogs.xmatch_ned(
|
|
643
|
+
obj[cand_idx],
|
|
644
|
+
sr,
|
|
645
|
+
col_id=col_id,
|
|
646
|
+
col_ra=obj_col_ra,
|
|
647
|
+
col_dec=obj_col_dec,
|
|
648
|
+
)
|
|
649
|
+
if xcat is not None and len(xcat):
|
|
650
|
+
cand_idx &= ~np.in1d(obj[col_id], xcat[col_id])
|
|
651
|
+
|
|
652
|
+
if remove == False:
|
|
653
|
+
obj_in['candidate_ned'][np.in1d(obj[col_id], xcat[col_id])] = True
|
|
654
|
+
|
|
655
|
+
log(np.sum(cand_idx), 'remains after matching with NED')
|
|
656
|
+
|
|
657
|
+
if remove == False:
|
|
658
|
+
obj_in['candidate_good'] = False
|
|
659
|
+
obj_in['candidate_good'][cand_idx] = True
|
|
660
|
+
|
|
661
|
+
log('%d candidates remaining after filtering' % len(obj[cand_idx]))
|
|
662
|
+
|
|
663
|
+
if get_candidates:
|
|
664
|
+
if remove:
|
|
665
|
+
# Return filtered list
|
|
666
|
+
return obj_in[cand_idx].copy()
|
|
667
|
+
else:
|
|
668
|
+
# Return full list with additional columns
|
|
669
|
+
return obj_in
|
|
670
|
+
else:
|
|
671
|
+
# Return just indices
|
|
672
|
+
return cand_idx
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def calibrate_photometry(
|
|
676
|
+
obj,
|
|
677
|
+
cat,
|
|
678
|
+
sr=None,
|
|
679
|
+
pixscale=None,
|
|
680
|
+
order=0,
|
|
681
|
+
bg_order=None,
|
|
682
|
+
obj_col_mag='mag',
|
|
683
|
+
obj_col_mag_err='magerr',
|
|
684
|
+
obj_col_ra='ra',
|
|
685
|
+
obj_col_dec='dec',
|
|
686
|
+
obj_col_flags='flags',
|
|
687
|
+
obj_col_x='x',
|
|
688
|
+
obj_col_y='y',
|
|
689
|
+
cat_col_mag='R',
|
|
690
|
+
cat_col_mag_err=None,
|
|
691
|
+
cat_col_mag1=None,
|
|
692
|
+
cat_col_mag2=None,
|
|
693
|
+
cat_col_ra='RAJ2000',
|
|
694
|
+
cat_col_dec='DEJ2000',
|
|
695
|
+
update=True,
|
|
696
|
+
verbose=False,
|
|
697
|
+
**kwargs,
|
|
698
|
+
):
|
|
699
|
+
"""Higher-level photometric calibration routine.
|
|
700
|
+
|
|
701
|
+
Wraps :func:`stdpipe.photometry.match` with convenient defaults for typical
|
|
702
|
+
tabular data.
|
|
703
|
+
|
|
704
|
+
Parameters
|
|
705
|
+
----------
|
|
706
|
+
obj : astropy.table.Table
|
|
707
|
+
Table of detected objects.
|
|
708
|
+
cat : astropy.table.Table
|
|
709
|
+
Reference photometric catalogue.
|
|
710
|
+
sr : float, optional
|
|
711
|
+
Matching radius in degrees. Defaults to half the median FWHM in sky
|
|
712
|
+
units if ``pixscale`` is provided, otherwise 1 arcsec.
|
|
713
|
+
pixscale : float, optional
|
|
714
|
+
Pixel scale in degrees/pixel; used to compute the default matching radius.
|
|
715
|
+
order : int, optional
|
|
716
|
+
Spatial polynomial order for the zero-point model (0 = constant).
|
|
717
|
+
bg_order : int or None, optional
|
|
718
|
+
Spatial polynomial order for an additive flux background term.
|
|
719
|
+
None disables this term.
|
|
720
|
+
obj_col_mag : str, optional
|
|
721
|
+
Column name for object instrumental magnitude.
|
|
722
|
+
obj_col_mag_err : str, optional
|
|
723
|
+
Column name for object magnitude error.
|
|
724
|
+
obj_col_ra : str, optional
|
|
725
|
+
Column name for object Right Ascension.
|
|
726
|
+
obj_col_dec : str, optional
|
|
727
|
+
Column name for object Declination.
|
|
728
|
+
obj_col_flags : str, optional
|
|
729
|
+
Column name for object flags.
|
|
730
|
+
obj_col_x : str, optional
|
|
731
|
+
Column name for object x coordinate.
|
|
732
|
+
obj_col_y : str, optional
|
|
733
|
+
Column name for object y coordinate.
|
|
734
|
+
cat_col_mag : str, optional
|
|
735
|
+
Column name for catalogue magnitude.
|
|
736
|
+
cat_col_mag_err : str, optional
|
|
737
|
+
Column name for catalogue magnitude error.
|
|
738
|
+
cat_col_mag1 : str, optional
|
|
739
|
+
First catalogue magnitude column for the color term (e.g. ``'B'``).
|
|
740
|
+
cat_col_mag2 : str, optional
|
|
741
|
+
Second catalogue magnitude column for the color term (e.g. ``'R'``).
|
|
742
|
+
cat_col_ra : str, optional
|
|
743
|
+
Column name for catalogue Right Ascension.
|
|
744
|
+
cat_col_dec : str, optional
|
|
745
|
+
Column name for catalogue Declination.
|
|
746
|
+
update : bool, optional
|
|
747
|
+
If True, add ``mag_calib`` and ``mag_calib_err`` columns to ``obj``
|
|
748
|
+
in-place with the calibrated magnitude and its error.
|
|
749
|
+
verbose : bool or callable, optional
|
|
750
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
751
|
+
**kwargs
|
|
752
|
+
Additional keyword arguments passed to :func:`stdpipe.photometry.match`.
|
|
753
|
+
|
|
754
|
+
Returns
|
|
755
|
+
-------
|
|
756
|
+
dict or None
|
|
757
|
+
Dictionary with photometric results as returned by
|
|
758
|
+
:func:`stdpipe.photometry.match`, or None on failure.
|
|
759
|
+
"""
|
|
760
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
761
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
762
|
+
|
|
763
|
+
if sr is None:
|
|
764
|
+
if pixscale is not None:
|
|
765
|
+
# Matching radius of half FWHM
|
|
766
|
+
sr = np.median(obj['fwhm'] * pixscale) / 2
|
|
767
|
+
else:
|
|
768
|
+
# Fallback value of 1 arcsec, should be sensible for most catalogues
|
|
769
|
+
sr = 1 / 3600
|
|
770
|
+
|
|
771
|
+
log(
|
|
772
|
+
'Performing photometric calibration of %d objects vs %d catalogue stars'
|
|
773
|
+
% (len(obj), len(cat))
|
|
774
|
+
)
|
|
775
|
+
log(
|
|
776
|
+
'Using %.1f arcsec matching radius, %s magnitude and spatial order %d'
|
|
777
|
+
% (sr * 3600, cat_col_mag, order)
|
|
778
|
+
)
|
|
779
|
+
if cat_col_mag1 and cat_col_mag2:
|
|
780
|
+
log('Using (%s - %s) color for color term' % (cat_col_mag1, cat_col_mag2))
|
|
781
|
+
|
|
782
|
+
if cat_col_mag1 and cat_col_mag2:
|
|
783
|
+
color = cat[cat_col_mag1] - cat[cat_col_mag2]
|
|
784
|
+
else:
|
|
785
|
+
color = None
|
|
786
|
+
|
|
787
|
+
if cat_col_mag_err:
|
|
788
|
+
cat_magerr = cat[cat_col_mag_err]
|
|
789
|
+
else:
|
|
790
|
+
cat_magerr = None
|
|
791
|
+
|
|
792
|
+
m = photometry.match(
|
|
793
|
+
obj[obj_col_ra],
|
|
794
|
+
obj[obj_col_dec],
|
|
795
|
+
obj[obj_col_mag],
|
|
796
|
+
obj[obj_col_mag_err],
|
|
797
|
+
obj[obj_col_flags],
|
|
798
|
+
cat[cat_col_ra],
|
|
799
|
+
cat[cat_col_dec],
|
|
800
|
+
cat[cat_col_mag],
|
|
801
|
+
cat_magerr=cat_magerr,
|
|
802
|
+
sr=sr,
|
|
803
|
+
cat_color=color,
|
|
804
|
+
obj_x=obj[obj_col_x] if obj_col_x else None,
|
|
805
|
+
obj_y=obj[obj_col_y] if obj_col_y else None,
|
|
806
|
+
spatial_order=order,
|
|
807
|
+
bg_order=bg_order,
|
|
808
|
+
verbose=verbose,
|
|
809
|
+
**kwargs,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
if m:
|
|
813
|
+
log('Photometric calibration finished successfully.')
|
|
814
|
+
# if m['color_term']:
|
|
815
|
+
# log('Color term is %.2f' % m['color_term'])
|
|
816
|
+
|
|
817
|
+
m['cat_col_mag'] = cat_col_mag
|
|
818
|
+
if cat_col_mag1 and cat_col_mag2:
|
|
819
|
+
m['cat_col_mag1'] = cat_col_mag1
|
|
820
|
+
m['cat_col_mag2'] = cat_col_mag2
|
|
821
|
+
|
|
822
|
+
if update:
|
|
823
|
+
obj['mag_calib'] = obj[obj_col_mag] + m['zero_fn'](
|
|
824
|
+
obj[obj_col_x], obj[obj_col_y], obj[obj_col_mag]
|
|
825
|
+
)
|
|
826
|
+
obj['mag_calib_err'] = np.hypot(
|
|
827
|
+
obj[obj_col_mag_err],
|
|
828
|
+
m['zero_fn'](obj[obj_col_x], obj[obj_col_y], obj[obj_col_mag], get_err=True),
|
|
829
|
+
)
|
|
830
|
+
else:
|
|
831
|
+
log('Photometric calibration failed')
|
|
832
|
+
|
|
833
|
+
return m
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def make_random_stars(
|
|
837
|
+
width=None,
|
|
838
|
+
height=None,
|
|
839
|
+
shape=None,
|
|
840
|
+
nstars=100,
|
|
841
|
+
minflux=1,
|
|
842
|
+
maxflux=100000,
|
|
843
|
+
edge=0,
|
|
844
|
+
wcs=None,
|
|
845
|
+
verbose=False,
|
|
846
|
+
):
|
|
847
|
+
"""Generate a table of random stars.
|
|
848
|
+
|
|
849
|
+
Coordinates are distributed uniformly with
|
|
850
|
+
``edge <= x < width - edge`` and ``edge <= y < height - edge``.
|
|
851
|
+
Fluxes are log-uniform between ``minflux`` and ``maxflux``.
|
|
852
|
+
|
|
853
|
+
Parameters
|
|
854
|
+
----------
|
|
855
|
+
width : int, optional
|
|
856
|
+
Width of the image in pixels.
|
|
857
|
+
height : int, optional
|
|
858
|
+
Height of the image in pixels.
|
|
859
|
+
shape : tuple of int, optional
|
|
860
|
+
Image shape ``(height, width)``; used instead of ``width`` and ``height`` if set.
|
|
861
|
+
nstars : int, optional
|
|
862
|
+
Number of artificial stars to generate.
|
|
863
|
+
minflux : float, optional
|
|
864
|
+
Minimum star flux in ADU.
|
|
865
|
+
maxflux : float, optional
|
|
866
|
+
Maximum star flux in ADU.
|
|
867
|
+
edge : int, optional
|
|
868
|
+
Minimum distance to image edges for star placement.
|
|
869
|
+
wcs : astropy.wcs.WCS, optional
|
|
870
|
+
If provided, sky coordinates ``ra`` and ``dec`` are added to the catalogue.
|
|
871
|
+
verbose : bool or callable, optional
|
|
872
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
873
|
+
|
|
874
|
+
Returns
|
|
875
|
+
-------
|
|
876
|
+
astropy.table.Table
|
|
877
|
+
Catalogue with columns ``x``, ``y``, ``flux``, ``mag``, and optionally
|
|
878
|
+
``ra``, ``dec`` if ``wcs`` is provided.
|
|
879
|
+
"""
|
|
880
|
+
|
|
881
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
882
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
883
|
+
|
|
884
|
+
if (width is None or height is None) and shape is not None:
|
|
885
|
+
height, width = shape
|
|
886
|
+
|
|
887
|
+
cat = {
|
|
888
|
+
'x': np.random.uniform(edge, width - 1 - edge, nstars),
|
|
889
|
+
'y': np.random.uniform(edge, height - edge, nstars),
|
|
890
|
+
'flux': 10 ** np.random.uniform(np.log10(minflux), np.log10(maxflux), nstars),
|
|
891
|
+
}
|
|
892
|
+
cat = Table(cat)
|
|
893
|
+
|
|
894
|
+
if wcs is not None and wcs.celestial:
|
|
895
|
+
cat['ra'], cat['dec'] = wcs.all_pix2world(cat['x'], cat['y'], 0)
|
|
896
|
+
else:
|
|
897
|
+
cat['ra'], cat['dec'] = np.nan, np.nan
|
|
898
|
+
|
|
899
|
+
cat['mag'] = -2.5 * np.log10(cat['flux'])
|
|
900
|
+
|
|
901
|
+
return cat
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def place_random_stars(
|
|
905
|
+
image,
|
|
906
|
+
psf_model,
|
|
907
|
+
nstars=100,
|
|
908
|
+
minflux=1,
|
|
909
|
+
maxflux=100000,
|
|
910
|
+
gain=None,
|
|
911
|
+
saturation=65535,
|
|
912
|
+
edge=0,
|
|
913
|
+
wcs=None,
|
|
914
|
+
verbose=False,
|
|
915
|
+
):
|
|
916
|
+
"""Randomly place artificial stars into an image in-place.
|
|
917
|
+
|
|
918
|
+
Stars are added on top of the existing image content. Poissonian noise is
|
|
919
|
+
applied according to ``gain``, and pixel values are clipped at ``saturation``.
|
|
920
|
+
Coordinates are uniformly distributed; fluxes are log-uniform.
|
|
921
|
+
|
|
922
|
+
Parameters
|
|
923
|
+
----------
|
|
924
|
+
image : ndarray
|
|
925
|
+
2D image array; modified in-place.
|
|
926
|
+
psf_model : dict
|
|
927
|
+
PSF model structure as returned by :func:`stdpipe.psf.run_psfex`.
|
|
928
|
+
nstars : int, optional
|
|
929
|
+
Number of artificial stars to inject.
|
|
930
|
+
minflux : float, optional
|
|
931
|
+
Minimum star flux in ADU.
|
|
932
|
+
maxflux : float, optional
|
|
933
|
+
Maximum star flux in ADU.
|
|
934
|
+
gain : float, optional
|
|
935
|
+
Detector gain in e-/ADU; used to add Poissonian noise to each injected star.
|
|
936
|
+
saturation : float, optional
|
|
937
|
+
Saturation level in ADU; injected pixels above this are clipped.
|
|
938
|
+
edge : int, optional
|
|
939
|
+
Minimum distance to image edges for star placement.
|
|
940
|
+
wcs : astropy.wcs.WCS, optional
|
|
941
|
+
If provided, sky coordinates ``ra`` and ``dec`` are added to the catalogue.
|
|
942
|
+
verbose : bool or callable, optional
|
|
943
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
944
|
+
|
|
945
|
+
Returns
|
|
946
|
+
-------
|
|
947
|
+
astropy.table.Table
|
|
948
|
+
Catalogue of injected stars as returned by :func:`make_random_stars`,
|
|
949
|
+
with columns ``x``, ``y``, ``flux``, ``mag``, and optionally ``ra``, ``dec``.
|
|
950
|
+
"""
|
|
951
|
+
|
|
952
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
953
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
954
|
+
|
|
955
|
+
cat = make_random_stars(
|
|
956
|
+
shape=image.shape,
|
|
957
|
+
nstars=nstars,
|
|
958
|
+
minflux=minflux,
|
|
959
|
+
maxflux=maxflux,
|
|
960
|
+
edge=edge,
|
|
961
|
+
wcs=wcs,
|
|
962
|
+
verbose=verbose,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
for _ in cat:
|
|
966
|
+
psf.place_psf_stamp(image, psf_model, _['x'], _['y'], flux=_['flux'], gain=gain)
|
|
967
|
+
|
|
968
|
+
if saturation is not None:
|
|
969
|
+
image[image > saturation] = saturation
|
|
970
|
+
|
|
971
|
+
return cat
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def split_sub_fn(x1, y1, dx1, dy1, *args, get_origin=False, **kwargs):
|
|
975
|
+
""" """
|
|
976
|
+
result = []
|
|
977
|
+
|
|
978
|
+
if get_origin:
|
|
979
|
+
result += [x1, y1]
|
|
980
|
+
|
|
981
|
+
# Let's find WCS as we may need it later for converting sky coordinates
|
|
982
|
+
wcs = None
|
|
983
|
+
for arg in args + tuple(kwargs.values()):
|
|
984
|
+
if isinstance(arg, WCS):
|
|
985
|
+
wcs = arg
|
|
986
|
+
break
|
|
987
|
+
|
|
988
|
+
def handle_arg_fn(arg):
|
|
989
|
+
if isinstance(arg, np.ndarray):
|
|
990
|
+
# Image
|
|
991
|
+
return cutouts.crop_image(arg, x1, y1, dx1, dy1)
|
|
992
|
+
|
|
993
|
+
if isinstance(arg, fits.Header):
|
|
994
|
+
# FITS header
|
|
995
|
+
subheader = arg.copy()
|
|
996
|
+
subheader['NAXIS1'] = dx1
|
|
997
|
+
subheader['NAXIS2'] = dy1
|
|
998
|
+
|
|
999
|
+
# Adjust the WCS keywords if present
|
|
1000
|
+
if 'CRPIX1' in subheader and 'CRPIX2' in subheader:
|
|
1001
|
+
subheader['CRPIX1'] -= x1
|
|
1002
|
+
subheader['CRPIX2'] -= y1
|
|
1003
|
+
|
|
1004
|
+
# Crop position inside original frame
|
|
1005
|
+
subheader['CROP_X1'] = x1
|
|
1006
|
+
subheader['CROP_X2'] = x1 + dx1
|
|
1007
|
+
subheader['CROP_Y1'] = y1
|
|
1008
|
+
subheader['CROP_Y2'] = y1 + dy1
|
|
1009
|
+
|
|
1010
|
+
return subheader
|
|
1011
|
+
|
|
1012
|
+
if isinstance(arg, WCS):
|
|
1013
|
+
# WCS
|
|
1014
|
+
wcs1 = arg.deepcopy()
|
|
1015
|
+
# FIXME: is there any more 'official' way of shifting the WCS?
|
|
1016
|
+
wcs1.wcs.crpix[0] -= x1
|
|
1017
|
+
wcs1.wcs.crpix[1] -= y1
|
|
1018
|
+
|
|
1019
|
+
wcs1 = WCS(wcs1.to_header(relax=True))
|
|
1020
|
+
return wcs1
|
|
1021
|
+
|
|
1022
|
+
if isinstance(arg, Table):
|
|
1023
|
+
# Table
|
|
1024
|
+
table = arg.copy()
|
|
1025
|
+
|
|
1026
|
+
x, y = None, None
|
|
1027
|
+
|
|
1028
|
+
if 'x' in table.colnames and 'y' in table.colnames:
|
|
1029
|
+
x, y = table['x'].copy(), table['y'].copy()
|
|
1030
|
+
table['x'] -= x1
|
|
1031
|
+
table['y'] -= y1
|
|
1032
|
+
elif 'ra' in table.colnames and 'dec' in table.colnames and wcs is not None:
|
|
1033
|
+
x, y = wcs.all_world2pix(table['ra'], table['dec'], 0)
|
|
1034
|
+
elif 'RAJ2000' in table.colnames and 'DEJ2000' in table.colnames and wcs is not None:
|
|
1035
|
+
x, y = wcs.all_world2pix(table['RAJ2000'], table['DEJ2000'], 0)
|
|
1036
|
+
|
|
1037
|
+
if x is not None:
|
|
1038
|
+
idx = (x > x1) & (x < x1 + dx1)
|
|
1039
|
+
idx &= (y > y1) & (y < y1 + dy1)
|
|
1040
|
+
else:
|
|
1041
|
+
idx = np.ones(len(table), dtype=bool)
|
|
1042
|
+
|
|
1043
|
+
return table[idx]
|
|
1044
|
+
|
|
1045
|
+
if isinstance(arg, dict) and 'x0' in arg and 'y0' in arg:
|
|
1046
|
+
# PSF structure, but also maybe some other dict-based objects
|
|
1047
|
+
res = deepcopy(arg)
|
|
1048
|
+
res['x0'] -= x1
|
|
1049
|
+
res['y0'] -= y1
|
|
1050
|
+
|
|
1051
|
+
return res
|
|
1052
|
+
|
|
1053
|
+
return None
|
|
1054
|
+
|
|
1055
|
+
for arg in args + tuple(kwargs.values()):
|
|
1056
|
+
result.append(handle_arg_fn(arg))
|
|
1057
|
+
|
|
1058
|
+
return result
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def split_image(
|
|
1062
|
+
image,
|
|
1063
|
+
*args,
|
|
1064
|
+
nx=1,
|
|
1065
|
+
ny=None,
|
|
1066
|
+
overlap=0,
|
|
1067
|
+
xmin=None,
|
|
1068
|
+
xmax=None,
|
|
1069
|
+
ymin=None,
|
|
1070
|
+
ymax=None,
|
|
1071
|
+
get_index=False,
|
|
1072
|
+
get_origin=False,
|
|
1073
|
+
verbose=False,
|
|
1074
|
+
**kwargs,
|
|
1075
|
+
):
|
|
1076
|
+
"""Generator that splits an image into ``nx × ny`` sub-image blocks.
|
|
1077
|
+
|
|
1078
|
+
Also optionally yields sub-setted copies of any additional images, FITS
|
|
1079
|
+
headers, WCS solutions, PSF models, catalogues, or object lists passed as
|
|
1080
|
+
positional or keyword arguments. FITS headers and WCS solutions are
|
|
1081
|
+
adjusted to reflect the sub-image astrometry. Tables are filtered to rows
|
|
1082
|
+
whose ``x``/``y``, ``ra``/``dec``, or ``RAJ2000``/``DEJ2000`` coordinates
|
|
1083
|
+
fall inside the sub-image.
|
|
1084
|
+
|
|
1085
|
+
Sub-images may optionally overlap by ``overlap`` pixels in every direction,
|
|
1086
|
+
so that each part of the original image appears far from an edge in at least
|
|
1087
|
+
one block.
|
|
1088
|
+
|
|
1089
|
+
Parameters
|
|
1090
|
+
----------
|
|
1091
|
+
image : ndarray
|
|
1092
|
+
2D image to split.
|
|
1093
|
+
*args
|
|
1094
|
+
Additional images, headers, WCS objects, or tables to crop alongside ``image``.
|
|
1095
|
+
nx : int, optional
|
|
1096
|
+
Number of sub-images along the x axis.
|
|
1097
|
+
ny : int, optional
|
|
1098
|
+
Number of sub-images along the y axis. Defaults to ``nx``.
|
|
1099
|
+
overlap : int, optional
|
|
1100
|
+
Number of pixels by which adjacent blocks overlap.
|
|
1101
|
+
xmin, xmax : int, optional
|
|
1102
|
+
Horizontal extent of the region to split (defaults to full image width).
|
|
1103
|
+
ymin, ymax : int, optional
|
|
1104
|
+
Vertical extent of the region to split (defaults to full image height).
|
|
1105
|
+
get_index : bool, optional
|
|
1106
|
+
If True, prepend the sub-image index (0-based) to each yielded list.
|
|
1107
|
+
get_origin : bool, optional
|
|
1108
|
+
If True, include the sub-image origin ``(x1, y1)`` in each yielded list.
|
|
1109
|
+
verbose : bool or callable, optional
|
|
1110
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
1111
|
+
**kwargs
|
|
1112
|
+
Additional images, headers, WCS objects, or tables to crop.
|
|
1113
|
+
|
|
1114
|
+
Yields
|
|
1115
|
+
------
|
|
1116
|
+
list
|
|
1117
|
+
Each element is a list built from (in order):
|
|
1118
|
+
|
|
1119
|
+
- sub-image index, if ``get_index=True``
|
|
1120
|
+
- origin ``x1``, ``y1``, if ``get_origin=True``
|
|
1121
|
+
- cropped image
|
|
1122
|
+
- cropped versions of each ``*args`` and ``**kwargs`` entry
|
|
1123
|
+
"""
|
|
1124
|
+
|
|
1125
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1126
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1127
|
+
|
|
1128
|
+
if not ny:
|
|
1129
|
+
ny = nx
|
|
1130
|
+
|
|
1131
|
+
# Sub-region to split
|
|
1132
|
+
if xmin is None:
|
|
1133
|
+
xmin = 0
|
|
1134
|
+
else:
|
|
1135
|
+
xmin = int(max(0, xmin))
|
|
1136
|
+
|
|
1137
|
+
if xmax is None:
|
|
1138
|
+
xmax = image.shape[1]
|
|
1139
|
+
else:
|
|
1140
|
+
xmax = int(min(image.shape[1], xmax))
|
|
1141
|
+
|
|
1142
|
+
if ymin is None:
|
|
1143
|
+
ymin = 0
|
|
1144
|
+
else:
|
|
1145
|
+
ymin = int(max(0, ymin))
|
|
1146
|
+
|
|
1147
|
+
if ymax is None:
|
|
1148
|
+
ymax = image.shape[0]
|
|
1149
|
+
else:
|
|
1150
|
+
ymax = int(min(image.shape[0], ymax))
|
|
1151
|
+
|
|
1152
|
+
width = xmax - xmin
|
|
1153
|
+
height = ymax - ymin
|
|
1154
|
+
|
|
1155
|
+
dx, dy = int(np.floor(width / nx)), int(np.floor(height / ny))
|
|
1156
|
+
|
|
1157
|
+
log(
|
|
1158
|
+
'Will split the image (%dx%d) into %dx%d pieces with %dx%d pixels size and %d pix overlap'
|
|
1159
|
+
% (width, height, nx, ny, dx, dy, overlap)
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
for i, (x0, y0) in enumerate(
|
|
1163
|
+
itertools.product(range(xmin, xmax - dx + 1, dx), range(ymin, ymax - dy + 1, dy))
|
|
1164
|
+
):
|
|
1165
|
+
# Make some overlap
|
|
1166
|
+
x1 = max(0, x0 - overlap)
|
|
1167
|
+
y1 = max(0, y0 - overlap)
|
|
1168
|
+
dx1 = min(x0 - x1 + dx + overlap, xmax - x1)
|
|
1169
|
+
dy1 = min(y0 - y1 + dy + overlap, ymax - y1)
|
|
1170
|
+
|
|
1171
|
+
result = split_sub_fn(x1, y1, dx1, dy1, image, *args, get_origin=get_origin, **kwargs)
|
|
1172
|
+
|
|
1173
|
+
if get_index:
|
|
1174
|
+
result = [i] + result
|
|
1175
|
+
|
|
1176
|
+
log('Block %d: %d %d - %d %d' % (i, x1, y1, x1 + dx1, y1 + dy1))
|
|
1177
|
+
|
|
1178
|
+
yield result if len(result) > 1 else result[0]
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def get_subimage_centered(
|
|
1182
|
+
image,
|
|
1183
|
+
*args,
|
|
1184
|
+
x0=None,
|
|
1185
|
+
y0=None,
|
|
1186
|
+
width=None,
|
|
1187
|
+
height=None,
|
|
1188
|
+
get_origin=False,
|
|
1189
|
+
verbose=False,
|
|
1190
|
+
**kwargs,
|
|
1191
|
+
):
|
|
1192
|
+
"""Crop a sub-image centered at a given pixel position.
|
|
1193
|
+
|
|
1194
|
+
Also optionally crops any additional images, masks, headers, WCS solutions,
|
|
1195
|
+
PSF models, object lists, etc. passed as arguments. Behaviour is identical
|
|
1196
|
+
to :func:`split_image` for a single block.
|
|
1197
|
+
|
|
1198
|
+
Unlike :func:`stdpipe.utils.crop_image_centered`, this function accepts
|
|
1199
|
+
explicit output ``width`` and ``height``. If the requested region extends
|
|
1200
|
+
beyond the image boundary, the returned sub-image will be smaller (no
|
|
1201
|
+
padding is applied).
|
|
1202
|
+
|
|
1203
|
+
Parameters
|
|
1204
|
+
----------
|
|
1205
|
+
image : ndarray
|
|
1206
|
+
2D image to crop.
|
|
1207
|
+
*args
|
|
1208
|
+
Additional images, headers, WCS objects, or tables to crop alongside ``image``.
|
|
1209
|
+
x0 : int or float
|
|
1210
|
+
Pixel x coordinate of the desired center in the original image.
|
|
1211
|
+
y0 : int or float
|
|
1212
|
+
Pixel y coordinate of the desired center in the original image.
|
|
1213
|
+
width : int
|
|
1214
|
+
Pixel width of the sub-image.
|
|
1215
|
+
height : int, optional
|
|
1216
|
+
Pixel height of the sub-image. Defaults to ``width``.
|
|
1217
|
+
get_origin : bool, optional
|
|
1218
|
+
If True, include the sub-image origin ``(x1, y1)`` in the returned list.
|
|
1219
|
+
verbose : bool or callable, optional
|
|
1220
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
1221
|
+
**kwargs
|
|
1222
|
+
Additional images, headers, WCS objects, or tables to crop.
|
|
1223
|
+
|
|
1224
|
+
Returns
|
|
1225
|
+
-------
|
|
1226
|
+
list
|
|
1227
|
+
List containing:
|
|
1228
|
+
|
|
1229
|
+
- origin ``x1``, ``y1``, if ``get_origin=True``
|
|
1230
|
+
- cropped image
|
|
1231
|
+
- cropped versions of each ``*args`` and ``**kwargs`` entry
|
|
1232
|
+
"""
|
|
1233
|
+
|
|
1234
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1235
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1236
|
+
|
|
1237
|
+
if not height:
|
|
1238
|
+
height = width
|
|
1239
|
+
|
|
1240
|
+
x1 = int(np.floor(x0 - 0.5 * width))
|
|
1241
|
+
y1 = int(np.floor(y0 - 0.5 * height))
|
|
1242
|
+
|
|
1243
|
+
x2 = x1 + width - 1
|
|
1244
|
+
y2 = y1 + height - 1
|
|
1245
|
+
|
|
1246
|
+
x1, x2 = max(0, x1), min(x2, image.shape[1] - 1)
|
|
1247
|
+
y1, y2 = max(0, y1), min(y2, image.shape[0] - 1)
|
|
1248
|
+
|
|
1249
|
+
log(
|
|
1250
|
+
'Will crop the %d %d - %d %d sub-image from original (%dx%d) image centered at %d %d'
|
|
1251
|
+
% (x1, y1, x2, y2, image.shape[1], image.shape[0], x0, y0)
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
result = split_sub_fn(
|
|
1255
|
+
x1, y1, x2 - x1 + 1, y2 - y1 + 1, image, *args, get_origin=get_origin, **kwargs
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
return result
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def get_detection_limit(obj, sn=5, method='sn', verbose=True):
|
|
1262
|
+
"""Estimate the detection limit magnitude.
|
|
1263
|
+
|
|
1264
|
+
The object table must contain calibrated magnitudes and errors in
|
|
1265
|
+
``mag_calib`` and ``mag_calib_err`` columns.
|
|
1266
|
+
|
|
1267
|
+
Parameters
|
|
1268
|
+
----------
|
|
1269
|
+
obj : astropy.table.Table
|
|
1270
|
+
Table with calibrated objects.
|
|
1271
|
+
sn : float, optional
|
|
1272
|
+
S/N threshold defining the detection limit.
|
|
1273
|
+
method : str, optional
|
|
1274
|
+
Estimation method. Currently only ``'sn'`` (S/N vs magnitude
|
|
1275
|
+
extrapolation) is implemented.
|
|
1276
|
+
verbose : bool or callable, optional
|
|
1277
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
1278
|
+
|
|
1279
|
+
Returns
|
|
1280
|
+
-------
|
|
1281
|
+
float or None
|
|
1282
|
+
Magnitude corresponding to the detection limit at the given S/N level,
|
|
1283
|
+
or None if estimation fails.
|
|
1284
|
+
"""
|
|
1285
|
+
|
|
1286
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1287
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1288
|
+
|
|
1289
|
+
if method == 'sn':
|
|
1290
|
+
log('Estimating detection limit using S/N vs magnitude method')
|
|
1291
|
+
mag0 = photometry.get_detection_limit_sn(
|
|
1292
|
+
obj['mag_calib'], 1 / obj['magerr'], sn=sn, verbose=verbose
|
|
1293
|
+
)
|
|
1294
|
+
elif method == 'bg':
|
|
1295
|
+
log('Estimating detection limit using background noise method')
|
|
1296
|
+
raise RuntimeError('Not implemented')
|
|
1297
|
+
|
|
1298
|
+
if mag0 is not None:
|
|
1299
|
+
log('Detection limit at S/N=%g level is %.2f' % (sn, mag0))
|
|
1300
|
+
else:
|
|
1301
|
+
log('Error estimating the detection limit!')
|
|
1302
|
+
|
|
1303
|
+
return mag0
|