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