LSST Applications 28.0.0,g1653933729+a8ce1bb630,g1a997c3884+a8ce1bb630,g28da252d5a+5bd70b7e6d,g2bbee38e9b+638fca75ac,g2bc492864f+638fca75ac,g3156d2b45e+07302053f8,g347aa1857d+638fca75ac,g35bb328faa+a8ce1bb630,g3a166c0a6a+638fca75ac,g3e281a1b8c+7bbb0b2507,g4005a62e65+17cd334064,g414038480c+5b5cd4fff3,g41af890bb2+4ffae9de63,g4e1a3235cc+0f1912dca3,g6249c6f860+3c3976f90c,g80478fca09+46aba80bd6,g82479be7b0+77990446f6,g858d7b2824+78ba4d1ce1,g89c8672015+f667a5183b,g9125e01d80+a8ce1bb630,ga5288a1d22+2a6264e9ca,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gc22bb204ba+78ba4d1ce1,gc28159a63d+638fca75ac,gcf0d15dbbd+32ddb6096f,gd6b7c0dfd1+3e339405e9,gda3e153d99+78ba4d1ce1,gda6a2b7d83+32ddb6096f,gdaeeff99f8+1711a396fd,gdd5a9049c5+b18c39e5e3,ge2409df99d+a5e4577cdc,ge33fd446bb+78ba4d1ce1,ge79ae78c31+638fca75ac,gf0baf85859+64e8883e75,gf5289d68f6+e1b046a8d7,gfa443fc69c+91d9ed1ecf,gfda6b12a05+8419469a56
LSST Data Management Base Package
Loading...
Searching...
No Matches
calibrateImage.py
Go to the documentation of this file.
1# This file is part of pipe_tasks.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21
22__all__ = ["CalibrateImageTask", "CalibrateImageConfig", "NoPsfStarsToStarsMatchError"]
23
24import numpy as np
25
26import lsst.afw.table as afwTable
27import lsst.afw.image as afwImage
32import lsst.meas.base
35import lsst.meas.extensions.shapeHSM
36import lsst.pex.config as pexConfig
37import lsst.pipe.base as pipeBase
38from lsst.pipe.base import connectionTypes
39from lsst.utils.timer import timeMethod
40
41from . import measurePsf, repair, photoCal, computeExposureSummaryStats, snapCombine
42
43
44class NoPsfStarsToStarsMatchError(pipeBase.AlgorithmError):
45 """Raised when there are no matches between the psf_stars and stars
46 catalogs.
47 """
48 def __init__(self, *, n_psf_stars, n_stars):
49 msg = (f"No psf stars out of {n_psf_stars} matched {n_stars} calib stars."
50 " Downstream processes probably won't have useful stars in this case."
51 " Is `star_source_selector` too strict or is this a bad image?")
52 super().__init__(msg)
53 self.n_psf_stars = n_psf_stars
54 self.n_stars = n_stars
55
56 @property
57 def metadata(self):
58 return {"n_psf_stars": self.n_psf_stars,
59 "n_stars": self.n_stars
60 }
61
62
63class CalibrateImageConnections(pipeBase.PipelineTaskConnections,
64 dimensions=("instrument", "visit", "detector")):
65
66 astrometry_ref_cat = connectionTypes.PrerequisiteInput(
67 doc="Reference catalog to use for astrometric calibration.",
68 name="gaia_dr3_20230707",
69 storageClass="SimpleCatalog",
70 dimensions=("skypix",),
71 deferLoad=True,
72 multiple=True,
73 )
74 photometry_ref_cat = connectionTypes.PrerequisiteInput(
75 doc="Reference catalog to use for photometric calibration.",
76 name="ps1_pv3_3pi_20170110",
77 storageClass="SimpleCatalog",
78 dimensions=("skypix",),
79 deferLoad=True,
80 multiple=True
81 )
82
83 exposures = connectionTypes.Input(
84 doc="Exposure (or two snaps) to be calibrated, and detected and measured on.",
85 name="postISRCCD",
86 storageClass="Exposure",
87 multiple=True, # to handle 1 exposure or 2 snaps
88 dimensions=["instrument", "exposure", "detector"],
89 )
90
91 # outputs
92 initial_stars_schema = connectionTypes.InitOutput(
93 doc="Schema of the output initial stars catalog.",
94 name="initial_stars_schema",
95 storageClass="SourceCatalog",
96 )
97
98 # TODO DM-38732: We want some kind of flag on Exposures/Catalogs to make
99 # it obvious which components had failed to be computed/persisted.
100 exposure = connectionTypes.Output(
101 doc="Photometrically calibrated, background-subtracted exposure with fitted calibrations and "
102 "summary statistics. To recover the original exposure, first add the background "
103 "(`initial_pvi_background`), and then uncalibrate (divide by `initial_photoCalib_detector`).",
104 name="initial_pvi",
105 storageClass="ExposureF",
106 dimensions=("instrument", "visit", "detector"),
107 )
108 stars = connectionTypes.Output(
109 doc="Catalog of unresolved sources detected on the calibrated exposure.",
110 name="initial_stars_detector",
111 storageClass="ArrowAstropy",
112 dimensions=["instrument", "visit", "detector"],
113 )
114 stars_footprints = connectionTypes.Output(
115 doc="Catalog of unresolved sources detected on the calibrated exposure; "
116 "includes source footprints.",
117 name="initial_stars_footprints_detector",
118 storageClass="SourceCatalog",
119 dimensions=["instrument", "visit", "detector"],
120 )
121 applied_photo_calib = connectionTypes.Output(
122 doc="Photometric calibration that was applied to exposure.",
123 name="initial_photoCalib_detector",
124 storageClass="PhotoCalib",
125 dimensions=("instrument", "visit", "detector"),
126 )
127 background = connectionTypes.Output(
128 doc="Background models estimated during calibration task; calibrated to be in nJy units.",
129 name="initial_pvi_background",
130 storageClass="Background",
131 dimensions=("instrument", "visit", "detector"),
132 )
133
134 # Optional outputs
135 psf_stars_footprints = connectionTypes.Output(
136 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination; "
137 "includes source footprints.",
138 name="initial_psf_stars_footprints_detector",
139 storageClass="SourceCatalog",
140 dimensions=["instrument", "visit", "detector"],
141 )
142 psf_stars = connectionTypes.Output(
143 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination.",
144 name="initial_psf_stars_detector",
145 storageClass="ArrowAstropy",
146 dimensions=["instrument", "visit", "detector"],
147 )
148 astrometry_matches = connectionTypes.Output(
149 doc="Source to reference catalog matches from the astrometry solver.",
150 name="initial_astrometry_match_detector",
151 storageClass="Catalog",
152 dimensions=("instrument", "visit", "detector"),
153 )
154 photometry_matches = connectionTypes.Output(
155 doc="Source to reference catalog matches from the photometry solver.",
156 name="initial_photometry_match_detector",
157 storageClass="Catalog",
158 dimensions=("instrument", "visit", "detector"),
159 )
160
161 def __init__(self, *, config=None):
162 super().__init__(config=config)
163 if config.optional_outputs is None or "psf_stars" not in config.optional_outputs:
164 del self.psf_stars
165 if config.optional_outputs is None or "psf_stars_footprints" not in config.optional_outputs:
166 del self.psf_stars_footprints
167 if config.optional_outputs is None or "astrometry_matches" not in config.optional_outputs:
168 del self.astrometry_matches
169 if config.optional_outputs is None or "photometry_matches" not in config.optional_outputs:
170 del self.photometry_matches
171
172
173class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=CalibrateImageConnections):
174 optional_outputs = pexConfig.ListField(
175 doc="Which optional outputs to save (as their connection name)?"
176 " If None, do not output any of these datasets.",
177 dtype=str,
178 # TODO: note somewhere to disable this for benchmarking, but should
179 # we always have it on for production runs?
180 default=["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches"],
181 optional=True
182 )
183
184 # To generate catalog ids consistently across subtasks.
185 id_generator = lsst.meas.base.DetectorVisitIdGeneratorConfig.make_field()
186
187 snap_combine = pexConfig.ConfigurableField(
189 doc="Task to combine two snaps to make one exposure.",
190 )
191
192 # subtasks used during psf characterization
193 install_simple_psf = pexConfig.ConfigurableField(
195 doc="Task to install a simple PSF model into the input exposure to use "
196 "when detecting bright sources for PSF estimation.",
197 )
198 psf_repair = pexConfig.ConfigurableField(
199 target=repair.RepairTask,
200 doc="Task to repair cosmic rays on the exposure before PSF determination.",
201 )
202 psf_subtract_background = pexConfig.ConfigurableField(
204 doc="Task to perform intial background subtraction, before first detection pass.",
205 )
206 psf_detection = pexConfig.ConfigurableField(
208 doc="Task to detect sources for PSF determination."
209 )
210 psf_source_measurement = pexConfig.ConfigurableField(
212 doc="Task to measure sources to be used for psf estimation."
213 )
214 psf_measure_psf = pexConfig.ConfigurableField(
216 doc="Task to measure the psf on bright sources."
217 )
218 psf_normalized_calibration_flux = pexConfig.ConfigurableField(
220 doc="Task to normalize the calibration flux (e.g. compensated tophats) "
221 "for the bright stars used for psf estimation.",
222 )
223
224 # TODO DM-39203: we can remove aperture correction from this task once we are
225 # using the shape-based star/galaxy code.
226 measure_aperture_correction = pexConfig.ConfigurableField(
228 doc="Task to compute the aperture correction from the bright stars."
229 )
230
231 # subtasks used during star measurement
232 star_detection = pexConfig.ConfigurableField(
234 doc="Task to detect stars to return in the output catalog."
235 )
236 star_sky_sources = pexConfig.ConfigurableField(
238 doc="Task to generate sky sources ('empty' regions where there are no detections).",
239 )
240 star_deblend = pexConfig.ConfigurableField(
242 doc="Split blended sources into their components."
243 )
244 star_measurement = pexConfig.ConfigurableField(
246 doc="Task to measure stars to return in the output catalog."
247 )
248 star_normalized_calibration_flux = pexConfig.ConfigurableField(
250 doc="Task to apply the normalization for calibration fluxes (e.g. compensated tophats) "
251 "for the final output star catalog.",
252 )
253 star_apply_aperture_correction = pexConfig.ConfigurableField(
255 doc="Task to apply aperture corrections to the selected stars."
256 )
257 star_catalog_calculation = pexConfig.ConfigurableField(
259 doc="Task to compute extendedness values on the star catalog, "
260 "for the star selector to remove extended sources."
261 )
262 star_set_primary_flags = pexConfig.ConfigurableField(
264 doc="Task to add isPrimary to the catalog."
265 )
266 star_selector = lsst.meas.algorithms.sourceSelectorRegistry.makeField(
267 default="science",
268 doc="Task to select reliable stars to use for calibration."
269 )
270
271 # final calibrations and statistics
272 astrometry = pexConfig.ConfigurableField(
274 doc="Task to perform astrometric calibration to fit a WCS.",
275 )
276 astrometry_ref_loader = pexConfig.ConfigField(
278 doc="Configuration of reference object loader for astrometric fit.",
279 )
280 photometry = pexConfig.ConfigurableField(
282 doc="Task to perform photometric calibration to fit a PhotoCalib.",
283 )
284 photometry_ref_loader = pexConfig.ConfigField(
286 doc="Configuration of reference object loader for photometric fit.",
287 )
288
289 compute_summary_stats = pexConfig.ConfigurableField(
291 doc="Task to to compute summary statistics on the calibrated exposure."
292 )
293
294 def setDefaults(self):
295 super().setDefaults()
296
297 # Use a very broad PSF here, to throughly reject CRs.
298 # TODO investigation: a large initial psf guess may make stars look
299 # like CRs for very good seeing images.
300 self.install_simple_psf.fwhm = 4
301
302 # S/N>=50 sources for PSF determination, but detection to S/N=5.
303 # The thresholdValue sets the minimum flux in a pixel to be included in the
304 # footprint, while peaks are only detected when they are above
305 # thresholdValue * includeThresholdMultiplier. The low thresholdValue
306 # ensures that the footprints are large enough for the noise replacer
307 # to mask out faint undetected neighbors that are not to be measured.
308 self.psf_detection.thresholdValue = 5.0
309 self.psf_detection.includeThresholdMultiplier = 10.0
310 # TODO investigation: Probably want False here, but that may require
311 # tweaking the background spatial scale, to make it small enough to
312 # prevent extra peaks in the wings of bright objects.
313 self.psf_detection.doTempLocalBackground = False
314 # NOTE: we do want reEstimateBackground=True in psf_detection, so that
315 # each measurement step is done with the best background available.
316
317 # Minimal measurement plugins for PSF determination.
318 # TODO DM-39203: We can drop GaussianFlux and PsfFlux, if we use
319 # shapeHSM/moments for star/galaxy separation.
320 # TODO DM-39203: we can remove aperture correction from this task once
321 # we are using the shape-based star/galaxy code.
322 self.psf_source_measurement.plugins = ["base_PixelFlags",
323 "base_SdssCentroid",
324 "ext_shapeHSM_HsmSourceMoments",
325 "base_CircularApertureFlux",
326 "base_GaussianFlux",
327 "base_PsfFlux",
328 "base_CompensatedTophatFlux",
329 ]
330 self.psf_source_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
331 # Only measure apertures we need for PSF measurement.
332 self.psf_source_measurement.plugins["base_CircularApertureFlux"].radii = [12.0]
333 self.psf_source_measurement.plugins["base_CompensatedTophatFlux"].apertures = [12]
334 # TODO DM-40843: Remove this line once this is the psfex default.
335 self.psf_measure_psf.psfDeterminer["psfex"].photometricFluxField = \
336 "base_CircularApertureFlux_12_0_instFlux"
337
338 # No extendeness information available: we need the aperture
339 # corrections to determine that.
340 self.measure_aperture_correction.sourceSelector["science"].doUnresolved = False
341 self.measure_aperture_correction.sourceSelector["science"].flags.good = ["calib_psf_used"]
342 self.measure_aperture_correction.sourceSelector["science"].flags.bad = []
343
344 # Detection for good S/N for astrometry/photometry and other
345 # downstream tasks; detection mask to S/N>=5, but S/N>=10 peaks.
346 self.star_detection.thresholdValue = 5.0
347 self.star_detection.includeThresholdMultiplier = 2.0
348 self.star_measurement.plugins = ["base_PixelFlags",
349 "base_SdssCentroid",
350 "ext_shapeHSM_HsmSourceMoments",
351 'ext_shapeHSM_HsmPsfMoments',
352 "base_GaussianFlux",
353 "base_PsfFlux",
354 "base_CircularApertureFlux",
355 "base_ClassificationSizeExtendedness",
356 "base_CompensatedTophatFlux",
357 ]
358 self.star_measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
359 self.star_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
360 # Only measure the apertures we need for star selection.
361 self.star_measurement.plugins["base_CircularApertureFlux"].radii = [12.0]
362 self.star_measurement.plugins["base_CompensatedTophatFlux"].apertures = [12]
363
364 # We measure and apply the normalization aperture correction with the
365 # psf_normalized_calibration_flux task, and we only apply the normalization
366 # aperture correction for the full list of stars.
367 self.star_normalized_calibration_flux.do_measure_ap_corr = False
368
369 # Select stars with reliable measurements and no bad flags.
370 self.star_selector["science"].doFlags = True
371 self.star_selector["science"].doUnresolved = True
372 self.star_selector["science"].doSignalToNoise = True
373 self.star_selector["science"].signalToNoise.minimum = 10.0
374 # Keep sky sources in the output catalog, even though they aren't
375 # wanted for calibration.
376 self.star_selector["science"].doSkySources = True
377
378 # Use the affine WCS fitter (assumes we have a good camera geometry).
379 self.astrometry.wcsFitter.retarget(lsst.meas.astrom.FitAffineWcsTask)
380 # phot_g_mean is the primary Gaia band for all input bands.
381 self.astrometry_ref_loader.anyFilterMapsToThis = "phot_g_mean"
382
383 # Only reject sky sources; we already selected good stars.
384 self.astrometry.sourceSelector["science"].doFlags = True
385 self.astrometry.sourceSelector["science"].flags.bad = ["sky_source"]
386 self.photometry.match.sourceSelection.doFlags = True
387 self.photometry.match.sourceSelection.flags.bad = ["sky_source"]
388 # Unset the (otherwise reasonable, but we've already made the
389 # selections we want above) selection settings in PhotoCalTask.
390 self.photometry.match.sourceSelection.doRequirePrimary = False
391 self.photometry.match.sourceSelection.doUnresolved = False
392
393 # All sources should be good for PSF summary statistics.
394 # TODO: These should both be changed to calib_psf_used with DM-41640.
395 self.compute_summary_stats.starSelection = "calib_photometry_used"
396 self.compute_summary_stats.starSelector.flags.good = ["calib_photometry_used"]
397
398 def validate(self):
399 super().validate()
400
401 # Ensure that the normalization calibration flux tasks
402 # are configured correctly.
403 if not self.psf_normalized_calibration_flux.do_measure_ap_corr:
404 msg = ("psf_normalized_calibration_flux task must be configured with do_measure_ap_corr=True "
405 "or else the normalization and calibration flux will not be properly measured.")
406 raise pexConfig.FieldValidationError(
407 CalibrateImageConfig.psf_normalized_calibration_flux, self, msg,
408 )
409 if self.star_normalized_calibration_flux.do_measure_ap_corr:
410 msg = ("star_normalized_calibration_flux task must be configured with do_measure_ap_corr=False "
411 "to apply the previously measured normalization to the full catalog of calibration "
412 "fluxes.")
413 raise pexConfig.FieldValidationError(
414 CalibrateImageConfig.star_normalized_calibration_flux, self, msg,
415 )
416
417
418class CalibrateImageTask(pipeBase.PipelineTask):
419 """Compute the PSF, aperture corrections, astrometric and photometric
420 calibrations, and summary statistics for a single science exposure, and
421 produce a catalog of brighter stars that were used to calibrate it.
422
423 Parameters
424 ----------
425 initial_stars_schema : `lsst.afw.table.Schema`
426 Schema of the initial_stars output catalog.
427 """
428 _DefaultName = "calibrateImage"
429 ConfigClass = CalibrateImageConfig
430
431 def __init__(self, initial_stars_schema=None, **kwargs):
432 super().__init__(**kwargs)
433
434 self.makeSubtask("snap_combine")
435
436 # PSF determination subtasks
437 self.makeSubtask("install_simple_psf")
438 self.makeSubtask("psf_repair")
439 self.makeSubtask("psf_subtract_background")
440 self.psf_schema = afwTable.SourceTable.makeMinimalSchema()
441 self.makeSubtask("psf_detection", schema=self.psf_schema)
442 self.makeSubtask("psf_source_measurement", schema=self.psf_schema)
443 self.makeSubtask("psf_measure_psf", schema=self.psf_schema)
444 self.makeSubtask("psf_normalized_calibration_flux", schema=self.psf_schema)
445
446 self.makeSubtask("measure_aperture_correction", schema=self.psf_schema)
447
448 # star measurement subtasks
449 if initial_stars_schema is None:
450 initial_stars_schema = afwTable.SourceTable.makeMinimalSchema()
451
452 # These fields let us track which sources were used for psf and
453 # aperture correction calculations.
454 self.psf_fields = ("calib_psf_candidate", "calib_psf_used", "calib_psf_reserved",
455 # TODO DM-39203: these can be removed once apcorr is gone.
456 "apcorr_slot_CalibFlux_used", "apcorr_base_GaussianFlux_used",
457 "apcorr_base_PsfFlux_used")
458 for field in self.psf_fields:
459 item = self.psf_schema.find(field)
460 initial_stars_schema.addField(item.getField())
461
462 afwTable.CoordKey.addErrorFields(initial_stars_schema)
463 self.makeSubtask("star_detection", schema=initial_stars_schema)
464 self.makeSubtask("star_sky_sources", schema=initial_stars_schema)
465 self.makeSubtask("star_deblend", schema=initial_stars_schema)
466 self.makeSubtask("star_measurement", schema=initial_stars_schema)
467 self.makeSubtask("star_normalized_calibration_flux", schema=initial_stars_schema)
468
469 self.makeSubtask("star_apply_aperture_correction", schema=initial_stars_schema)
470 self.makeSubtask("star_catalog_calculation", schema=initial_stars_schema)
471 self.makeSubtask("star_set_primary_flags", schema=initial_stars_schema, isSingleFrame=True)
472 self.makeSubtask("star_selector")
473
474 self.makeSubtask("astrometry", schema=initial_stars_schema)
475 self.makeSubtask("photometry", schema=initial_stars_schema)
476
477 self.makeSubtask("compute_summary_stats")
478
479 # For the butler to persist it.
480 self.initial_stars_schema = afwTable.SourceCatalog(initial_stars_schema)
481
482 def runQuantum(self, butlerQC, inputRefs, outputRefs):
483 inputs = butlerQC.get(inputRefs)
484 exposures = inputs.pop("exposures")
485
486 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId)
487
489 dataIds=[ref.datasetRef.dataId for ref in inputRefs.astrometry_ref_cat],
490 refCats=inputs.pop("astrometry_ref_cat"),
491 name=self.config.connections.astrometry_ref_cat,
492 config=self.config.astrometry_ref_loader, log=self.log)
493 self.astrometry.setRefObjLoader(astrometry_loader)
494
496 dataIds=[ref.datasetRef.dataId for ref in inputRefs.photometry_ref_cat],
497 refCats=inputs.pop("photometry_ref_cat"),
498 name=self.config.connections.photometry_ref_cat,
499 config=self.config.photometry_ref_loader, log=self.log)
500 self.photometry.match.setRefObjLoader(photometry_loader)
501
502 # This should not happen with a properly configured execution context.
503 assert not inputs, "runQuantum got more inputs than expected"
504
505 # Specify the fields that `annotate` needs below, to ensure they
506 # exist, even as None.
507 result = pipeBase.Struct(exposure=None,
508 stars_footprints=None,
509 psf_stars_footprints=None,
510 )
511 try:
512 self.run(exposures=exposures, result=result, id_generator=id_generator)
513 except pipeBase.AlgorithmError as e:
514 error = pipeBase.AnnotatedPartialOutputsError.annotate(
515 e,
516 self,
517 result.exposure,
518 result.psf_stars_footprints,
519 result.stars_footprints,
520 log=self.log
521 )
522 butlerQC.put(result, outputRefs)
523 raise error from e
524
525 butlerQC.put(result, outputRefs)
526
527 @timeMethod
528 def run(self, *, exposures, id_generator=None, result=None):
529 """Find stars and perform psf measurement, then do a deeper detection
530 and measurement and calibrate astrometry and photometry from that.
531
532 Parameters
533 ----------
534 exposures : `lsst.afw.image.Exposure` or `list` [`lsst.afw.image.Exposure`]
535 Post-ISR exposure(s), with an initial WCS, VisitInfo, and Filter.
536 Modified in-place during processing if only one is passed.
537 If two exposures are passed, treat them as snaps and combine
538 before doing further processing.
539 id_generator : `lsst.meas.base.IdGenerator`, optional
540 Object that generates source IDs and provides random seeds.
541 result : `lsst.pipe.base.Struct`, optional
542 Result struct that is modified to allow saving of partial outputs
543 for some failure conditions. If the task completes successfully,
544 this is also returned.
545
546 Returns
547 -------
548 result : `lsst.pipe.base.Struct`
549 Results as a struct with attributes:
550
551 ``exposure``
552 Calibrated exposure, with pixels in nJy units.
553 (`lsst.afw.image.Exposure`)
554 ``stars``
555 Stars that were used to calibrate the exposure, with
556 calibrated fluxes and magnitudes.
557 (`astropy.table.Table`)
558 ``stars_footprints``
559 Footprints of stars that were used to calibrate the exposure.
560 (`lsst.afw.table.SourceCatalog`)
561 ``psf_stars``
562 Stars that were used to determine the image PSF.
563 (`astropy.table.Table`)
564 ``psf_stars_footprints``
565 Footprints of stars that were used to determine the image PSF.
566 (`lsst.afw.table.SourceCatalog`)
567 ``background``
568 Background that was fit to the exposure when detecting
569 ``stars``. (`lsst.afw.math.BackgroundList`)
570 ``applied_photo_calib``
571 Photometric calibration that was fit to the star catalog and
572 applied to the exposure. (`lsst.afw.image.PhotoCalib`)
573 ``astrometry_matches``
574 Reference catalog stars matches used in the astrometric fit.
575 (`list` [`lsst.afw.table.ReferenceMatch`] or `lsst.afw.table.BaseCatalog`)
576 ``photometry_matches``
577 Reference catalog stars matches used in the photometric fit.
578 (`list` [`lsst.afw.table.ReferenceMatch`] or `lsst.afw.table.BaseCatalog`)
579 """
580 if result is None:
581 result = pipeBase.Struct()
582 if id_generator is None:
583 id_generator = lsst.meas.base.IdGenerator()
584
585 result.exposure = self.snap_combine.run(exposures).exposure
586
587 result.psf_stars_footprints, result.background, candidates = self._compute_psf(result.exposure,
588 id_generator)
589 self._measure_aperture_correction(result.exposure, result.psf_stars_footprints)
590
591 result.psf_stars = result.psf_stars_footprints.asAstropy()
592
593 result.stars_footprints = self._find_stars(result.exposure, result.background, id_generator)
594 self._match_psf_stars(result.psf_stars_footprints, result.stars_footprints)
595 result.stars = result.stars_footprints.asAstropy()
596
597 astrometry_matches, astrometry_meta = self._fit_astrometry(result.exposure, result.stars_footprints)
598 if self.config.optional_outputs is not None and "astrometry_matches" in self.config.optional_outputs:
599 result.astrometry_matches = lsst.meas.astrom.denormalizeMatches(astrometry_matches,
600 astrometry_meta)
601
602 result.stars_footprints, photometry_matches, \
603 photometry_meta, result.applied_photo_calib = self._fit_photometry(result.exposure,
604 result.stars_footprints,
605 result.background)
606 # fit_photometry returns a new catalog, so we need a new astropy table view.
607 result.stars = result.stars_footprints.asAstropy()
608 if self.config.optional_outputs is not None and "photometry_matches" in self.config.optional_outputs:
609 result.photometry_matches = lsst.meas.astrom.denormalizeMatches(photometry_matches,
610 photometry_meta)
611
612 self._summarize(result.exposure, result.stars_footprints, result.background)
613
614 return result
615
616 def _compute_psf(self, exposure, id_generator):
617 """Find bright sources detected on an exposure and fit a PSF model to
618 them, repairing likely cosmic rays before detection.
619
620 Repair, detect, measure, and compute PSF twice, to ensure the PSF
621 model does not include contributions from cosmic rays.
622
623 Parameters
624 ----------
625 exposure : `lsst.afw.image.Exposure`
626 Exposure to detect and measure bright stars on.
627 id_generator : `lsst.meas.base.IdGenerator`, optional
628 Object that generates source IDs and provides random seeds.
629
630 Returns
631 -------
632 sources : `lsst.afw.table.SourceCatalog`
633 Catalog of detected bright sources.
634 background : `lsst.afw.math.BackgroundList`
635 Background that was fit to the exposure during detection.
636 cell_set : `lsst.afw.math.SpatialCellSet`
637 PSF candidates returned by the psf determiner.
638 """
639 def log_psf(msg):
640 """Log the parameters of the psf and background, with a prepended
641 message.
642 """
643 position = exposure.psf.getAveragePosition()
644 sigma = exposure.psf.computeShape(position).getDeterminantRadius()
645 dimensions = exposure.psf.computeImage(position).getDimensions()
646 median_background = np.median(background.getImage().array)
647 self.log.info("%s sigma=%0.4f, dimensions=%s; median background=%0.2f",
648 msg, sigma, dimensions, median_background)
649
650 self.log.info("First pass detection with Guassian PSF FWHM=%s pixels",
651 self.config.install_simple_psf.fwhm)
652 self.install_simple_psf.run(exposure=exposure)
653
654 background = self.psf_subtract_background.run(exposure=exposure).background
655 log_psf("Initial PSF:")
656 self.psf_repair.run(exposure=exposure, keepCRs=True)
657
658 table = afwTable.SourceTable.make(self.psf_schema, id_generator.make_table_id_factory())
659 # Re-estimate the background during this detection step, so that
660 # measurement uses the most accurate background-subtraction.
661 detections = self.psf_detection.run(table=table, exposure=exposure, background=background)
662 self.psf_source_measurement.run(detections.sources, exposure)
663 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources)
664 # Replace the initial PSF with something simpler for the second
665 # repair/detect/measure/measure_psf step: this can help it converge.
666 self.install_simple_psf.run(exposure=exposure)
667
668 log_psf("Rerunning with simple PSF:")
669 # TODO investigation: Should we only re-run repair here, to use the
670 # new PSF? Maybe we *do* need to re-run measurement with PsfFlux, to
671 # use the fitted PSF?
672 # TODO investigation: do we need a separate measurement task here
673 # for the post-psf_measure_psf step, since we only want to do PsfFlux
674 # and GaussianFlux *after* we have a PSF? Maybe that's not relevant
675 # once DM-39203 is merged?
676 self.psf_repair.run(exposure=exposure, keepCRs=True)
677 # Re-estimate the background during this detection step, so that
678 # measurement uses the most accurate background-subtraction.
679 detections = self.psf_detection.run(table=table, exposure=exposure, background=background)
680 self.psf_source_measurement.run(detections.sources, exposure)
681 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources)
682
683 log_psf("Final PSF:")
684
685 # Final repair with final PSF, removing cosmic rays this time.
686 self.psf_repair.run(exposure=exposure)
687 # Final measurement with the CRs removed.
688 self.psf_source_measurement.run(detections.sources, exposure)
689
690 # PSF is set on exposure; candidates are returned to use for
691 # calibration flux normalization and aperture corrections.
692 return detections.sources, background, psf_result.cellSet
693
694 def _measure_aperture_correction(self, exposure, bright_sources):
695 """Measure and set the ApCorrMap on the Exposure, using
696 previously-measured bright sources.
697
698 This function first normalizes the calibration flux and then
699 the full set of aperture corrections are measured relative
700 to this normalized calibration flux.
701
702 Parameters
703 ----------
704 exposure : `lsst.afw.image.Exposure`
705 Exposure to set the ApCorrMap on.
706 bright_sources : `lsst.afw.table.SourceCatalog`
707 Catalog of detected bright sources; modified to include columns
708 necessary for point source determination for the aperture correction
709 calculation.
710 """
711 norm_ap_corr_map = self.psf_normalized_calibration_flux.run(
712 exposure=exposure,
713 catalog=bright_sources,
714 ).ap_corr_map
715
716 ap_corr_map = self.measure_aperture_correction.run(exposure, bright_sources).apCorrMap
717
718 # Need to merge the aperture correction map from the normalization.
719 for key in norm_ap_corr_map:
720 ap_corr_map[key] = norm_ap_corr_map[key]
721
722 exposure.info.setApCorrMap(ap_corr_map)
723
724 def _find_stars(self, exposure, background, id_generator):
725 """Detect stars on an exposure that has a PSF model, and measure their
726 PSF, circular aperture, compensated gaussian fluxes.
727
728 Parameters
729 ----------
730 exposure : `lsst.afw.image.Exposure`
731 Exposure to detect and measure stars on.
732 background : `lsst.afw.math.BackgroundList`
733 Background that was fit to the exposure during detection;
734 modified in-place during subsequent detection.
735 id_generator : `lsst.meas.base.IdGenerator`
736 Object that generates source IDs and provides random seeds.
737
738 Returns
739 -------
740 stars : `SourceCatalog`
741 Sources that are very likely to be stars, with a limited set of
742 measurements performed on them.
743 """
744 table = afwTable.SourceTable.make(self.initial_stars_schema.schema,
745 id_generator.make_table_id_factory())
746 # Re-estimate the background during this detection step, so that
747 # measurement uses the most accurate background-subtraction.
748 detections = self.star_detection.run(table=table, exposure=exposure, background=background)
749 sources = detections.sources
750 self.star_sky_sources.run(exposure.mask, id_generator.catalog_id, sources)
751
752 # TODO investigation: Could this deblender throw away blends of non-PSF sources?
753 self.star_deblend.run(exposure=exposure, sources=sources)
754 # The deblender may not produce a contiguous catalog; ensure
755 # contiguity for subsequent tasks.
756 if not sources.isContiguous():
757 sources = sources.copy(deep=True)
758
759 # Measure everything, and use those results to select only stars.
760 self.star_measurement.run(sources, exposure)
761 # Run the normalization calibration flux task to apply the
762 # normalization correction to create normalized
763 # calibration fluxes.
764 self.star_normalized_calibration_flux.run(exposure=exposure, catalog=sources)
765 self.star_apply_aperture_correction.run(sources, exposure.apCorrMap)
766 self.star_catalog_calculation.run(sources)
767 self.star_set_primary_flags.run(sources)
768
769 result = self.star_selector.run(sources)
770 # The star selector may not produce a contiguous catalog.
771 if not result.sourceCat.isContiguous():
772 return result.sourceCat.copy(deep=True)
773 else:
774 return result.sourceCat
775
776 def _match_psf_stars(self, psf_stars, stars):
777 """Match calibration stars to psf stars, to identify which were psf
778 candidates, and which were used or reserved during psf measurement.
779
780 Parameters
781 ----------
782 psf_stars : `lsst.afw.table.SourceCatalog`
783 PSF candidate stars that were sent to the psf determiner. Used to
784 populate psf-related flag fields.
785 stars : `lsst.afw.table.SourceCatalog`
786 Stars that will be used for calibration; psf-related fields will
787 be updated in-place.
788
789 Notes
790 -----
791 This code was adapted from CalibrateTask.copyIcSourceFields().
792 """
793 control = afwTable.MatchControl()
794 # Return all matched objects, to separate blends.
795 control.findOnlyClosest = False
796 matches = afwTable.matchXy(psf_stars, stars, 3.0, control)
797 deblend_key = stars.schema["deblend_nChild"].asKey()
798 matches = [m for m in matches if m[1].get(deblend_key) == 0]
799
800 # Because we had to allow multiple matches to handle parents, we now
801 # need to prune to the best (closest) matches.
802 # Closest matches is a dict of psf_stars source ID to Match record
803 # (psf_stars source, sourceCat source, distance in pixels).
804 best = {}
805 for match_psf, match_stars, d in matches:
806 match = best.get(match_psf.getId())
807 if match is None or d <= match[2]:
808 best[match_psf.getId()] = (match_psf, match_stars, d)
809 matches = list(best.values())
810 # We'll use this to construct index arrays into each catalog.
811 ids = np.array([(match_psf.getId(), match_stars.getId()) for match_psf, match_stars, d in matches]).T
812
813 if (n_matches := len(matches)) == 0:
814 raise NoPsfStarsToStarsMatchError(n_psf_stars=len(psf_stars), n_stars=len(stars))
815
816 self.log.info("%d psf stars out of %d matched %d calib stars", n_matches, len(psf_stars), len(stars))
817
818 # Check that no stars sources are listed twice; we already know
819 # that each match has a unique psf_stars id, due to using as the key
820 # in best above.
821 n_unique = len(set(m[1].getId() for m in matches))
822 if n_unique != n_matches:
823 self.log.warning("%d psf_stars matched only %d stars", n_matches, n_unique)
824
825 # The indices of the IDs, so we can update the flag fields as arrays.
826 idx_psf_stars = np.searchsorted(psf_stars["id"], ids[0])
827 idx_stars = np.searchsorted(stars["id"], ids[1])
828 for field in self.psf_fields:
829 result = np.zeros(len(stars), dtype=bool)
830 result[idx_stars] = psf_stars[field][idx_psf_stars]
831 stars[field] = result
832
833 def _fit_astrometry(self, exposure, stars):
834 """Fit an astrometric model to the data and return the reference
835 matches used in the fit, and the fitted WCS.
836
837 Parameters
838 ----------
839 exposure : `lsst.afw.image.Exposure`
840 Exposure that is being fit, to get PSF and other metadata from.
841 Modified to add the fitted skyWcs.
842 stars : `SourceCatalog`
843 Good stars selected for use in calibration, with RA/Dec coordinates
844 computed from the pixel positions and fitted WCS.
845
846 Returns
847 -------
848 matches : `list` [`lsst.afw.table.ReferenceMatch`]
849 Reference/stars matches used in the fit.
850 """
851 result = self.astrometry.run(stars, exposure)
852 return result.matches, result.matchMeta
853
854 def _fit_photometry(self, exposure, stars, background):
855 """Fit a photometric model to the data and return the reference
856 matches used in the fit, and the fitted PhotoCalib.
857
858 Parameters
859 ----------
860 exposure : `lsst.afw.image.Exposure`
861 Exposure that is being fit, to get PSF and other metadata from.
862 Modified to be in nanojanksy units, with an assigned photoCalib
863 identically 1.
864 stars : `lsst.afw.table.SourceCatalog`
865 Good stars selected for use in calibration.
866
867 Returns
868 -------
869 calibrated_stars : `lsst.afw.table.SourceCatalog`
870 Star catalog with flux/magnitude columns computed from the fitted
871 photoCalib.
872 matches : `list` [`lsst.afw.table.ReferenceMatch`]
873 Reference/stars matches used in the fit.
874 photoCalib : `lsst.afw.image.PhotoCalib`
875 Photometric calibration that was fit to the star catalog.
876 """
877 result = self.photometry.run(exposure, stars)
878 calibrated_stars = result.photoCalib.calibrateCatalog(stars)
879 exposure.maskedImage = result.photoCalib.calibrateImage(exposure.maskedImage)
880 identity = afwImage.PhotoCalib(1.0,
881 result.photoCalib.getCalibrationErr(),
882 bbox=exposure.getBBox())
883 exposure.setPhotoCalib(identity)
884
885 assert result.photoCalib._isConstant, \
886 "Background calibration assumes a constant PhotoCalib; PhotoCalTask should always return that."
887 for bg in background:
888 # The statsImage is a view, but we can't assign to a function call in python.
889 binned_image = bg[0].getStatsImage()
890 binned_image *= result.photoCalib.getCalibrationMean()
891
892 return calibrated_stars, result.matches, result.matchMeta, result.photoCalib
893
894 def _summarize(self, exposure, stars, background):
895 """Compute summary statistics on the exposure and update in-place the
896 calibrations attached to it.
897
898 Parameters
899 ----------
900 exposure : `lsst.afw.image.Exposure`
901 Exposure that was calibrated, to get PSF and other metadata from.
902 Modified to contain the computed summary statistics.
903 stars : `SourceCatalog`
904 Good stars selected used in calibration.
905 background : `lsst.afw.math.BackgroundList`
906 Background that was fit to the exposure during detection of the
907 above stars.
908 """
909 # TODO investigation: because this takes the photoCalib from the
910 # exposure, photometric summary values may be "incorrect" (i.e. they
911 # will reflect the ==1 nJy calibration on the exposure, not the
912 # applied calibration). This needs to be checked.
913 summary = self.compute_summary_stats.run(exposure, stars, background)
914 exposure.info.setSummaryStats(summary)
The photometric calibration of an exposure.
Definition PhotoCalib.h:114
Pass parameters to algorithms that match list of sources.
Definition Match.h:45
runQuantum(self, butlerQC, inputRefs, outputRefs)
_fit_photometry(self, exposure, stars, background)
_measure_aperture_correction(self, exposure, bright_sources)
_find_stars(self, exposure, background, id_generator)
__init__(self, initial_stars_schema=None, **kwargs)
_summarize(self, exposure, stars, background)
run(self, *, exposures, id_generator=None, result=None)
SourceMatchVector matchXy(SourceCatalog const &cat1, SourceCatalog const &cat2, double radius, MatchControl const &mc=MatchControl())
Compute all tuples (s1,s2,d) where s1 belings to cat1, s2 belongs to cat2 and d, the distance between...
Definition Match.cc:305