LSST Applications g063fba187b+cac8b7c890,g0f08755f38+6aee506743,g1653933729+a8ce1bb630,g168dd56ebc+a8ce1bb630,g1a2382251a+b4475c5878,g1dcb35cd9c+8f9bc1652e,g20f6ffc8e0+6aee506743,g217e2c1bcf+73dee94bd0,g28da252d5a+1f19c529b9,g2bbee38e9b+3f2625acfc,g2bc492864f+3f2625acfc,g3156d2b45e+6e55a43351,g32e5bea42b+1bb94961c2,g347aa1857d+3f2625acfc,g35bb328faa+a8ce1bb630,g3a166c0a6a+3f2625acfc,g3e281a1b8c+c5dd892a6c,g3e8969e208+a8ce1bb630,g414038480c+5927e1bc1e,g41af890bb2+8a9e676b2a,g7af13505b9+809c143d88,g80478fca09+6ef8b1810f,g82479be7b0+f568feb641,g858d7b2824+6aee506743,g89c8672015+f4add4ffd5,g9125e01d80+a8ce1bb630,ga5288a1d22+2903d499ea,gb58c049af0+d64f4d3760,gc28159a63d+3f2625acfc,gcab2d0539d+b12535109e,gcf0d15dbbd+46a3f46ba9,gda6a2b7d83+46a3f46ba9,gdaeeff99f8+1711a396fd,ge79ae78c31+3f2625acfc,gef2f8181fd+0a71e47438,gf0baf85859+c1f95f4921,gfa517265be+6aee506743,gfa999e8aa5+17cd334064,w.2024.51
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
28from lsst.ip.diffim.utils import evaluateMaskFraction
33import lsst.meas.base
36import lsst.meas.extensions.shapeHSM
37import lsst.pex.config as pexConfig
38import lsst.pipe.base as pipeBase
39from lsst.pipe.base import connectionTypes
40from lsst.utils.timer import timeMethod
41
42from . import measurePsf, repair, photoCal, computeExposureSummaryStats, snapCombine
43
44
45class NoPsfStarsToStarsMatchError(pipeBase.AlgorithmError):
46 """Raised when there are no matches between the psf_stars and stars
47 catalogs.
48 """
49 def __init__(self, *, n_psf_stars, n_stars):
50 msg = (f"No psf stars out of {n_psf_stars} matched {n_stars} calib stars."
51 " Downstream processes probably won't have useful stars in this case."
52 " Is `star_source_selector` too strict or is this a bad image?")
53 super().__init__(msg)
54 self.n_psf_stars = n_psf_stars
55 self.n_stars = n_stars
56
57 @property
58 def metadata(self):
59 return {"n_psf_stars": self.n_psf_stars,
60 "n_stars": self.n_stars
61 }
62
63
64class CalibrateImageConnections(pipeBase.PipelineTaskConnections,
65 dimensions=("instrument", "visit", "detector")):
66
67 astrometry_ref_cat = connectionTypes.PrerequisiteInput(
68 doc="Reference catalog to use for astrometric calibration.",
69 name="gaia_dr3_20230707",
70 storageClass="SimpleCatalog",
71 dimensions=("skypix",),
72 deferLoad=True,
73 multiple=True,
74 )
75 photometry_ref_cat = connectionTypes.PrerequisiteInput(
76 doc="Reference catalog to use for photometric calibration.",
77 name="ps1_pv3_3pi_20170110",
78 storageClass="SimpleCatalog",
79 dimensions=("skypix",),
80 deferLoad=True,
81 multiple=True
82 )
83
84 exposures = connectionTypes.Input(
85 doc="Exposure (or two snaps) to be calibrated, and detected and measured on.",
86 name="postISRCCD",
87 storageClass="Exposure",
88 multiple=True, # to handle 1 exposure or 2 snaps
89 dimensions=["instrument", "exposure", "detector"],
90 )
91
92 # outputs
93 initial_stars_schema = connectionTypes.InitOutput(
94 doc="Schema of the output initial stars catalog.",
95 name="initial_stars_schema",
96 storageClass="SourceCatalog",
97 )
98
99 # TODO DM-38732: We want some kind of flag on Exposures/Catalogs to make
100 # it obvious which components had failed to be computed/persisted.
101 exposure = connectionTypes.Output(
102 doc="Photometrically calibrated, background-subtracted exposure with fitted calibrations and "
103 "summary statistics. To recover the original exposure, first add the background "
104 "(`initial_pvi_background`), and then uncalibrate (divide by `initial_photoCalib_detector`).",
105 name="initial_pvi",
106 storageClass="ExposureF",
107 dimensions=("instrument", "visit", "detector"),
108 )
109 stars = connectionTypes.Output(
110 doc="Catalog of unresolved sources detected on the calibrated exposure.",
111 name="initial_stars_detector",
112 storageClass="ArrowAstropy",
113 dimensions=["instrument", "visit", "detector"],
114 )
115 stars_footprints = connectionTypes.Output(
116 doc="Catalog of unresolved sources detected on the calibrated exposure; "
117 "includes source footprints.",
118 name="initial_stars_footprints_detector",
119 storageClass="SourceCatalog",
120 dimensions=["instrument", "visit", "detector"],
121 )
122 applied_photo_calib = connectionTypes.Output(
123 doc=(
124 "Photometric calibration that was applied to exposure's pixels. "
125 "This connection is disabled when do_calibrate_pixels=False."
126 ),
127 name="initial_photoCalib_detector",
128 storageClass="PhotoCalib",
129 dimensions=("instrument", "visit", "detector"),
130 )
131 background = connectionTypes.Output(
132 doc="Background models estimated during calibration task; calibrated to be in nJy units.",
133 name="initial_pvi_background",
134 storageClass="Background",
135 dimensions=("instrument", "visit", "detector"),
136 )
137
138 # Optional outputs
139 psf_stars_footprints = connectionTypes.Output(
140 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination; "
141 "includes source footprints.",
142 name="initial_psf_stars_footprints_detector",
143 storageClass="SourceCatalog",
144 dimensions=["instrument", "visit", "detector"],
145 )
146 psf_stars = connectionTypes.Output(
147 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination.",
148 name="initial_psf_stars_detector",
149 storageClass="ArrowAstropy",
150 dimensions=["instrument", "visit", "detector"],
151 )
152 astrometry_matches = connectionTypes.Output(
153 doc="Source to reference catalog matches from the astrometry solver.",
154 name="initial_astrometry_match_detector",
155 storageClass="Catalog",
156 dimensions=("instrument", "visit", "detector"),
157 )
158 photometry_matches = connectionTypes.Output(
159 doc="Source to reference catalog matches from the photometry solver.",
160 name="initial_photometry_match_detector",
161 storageClass="Catalog",
162 dimensions=("instrument", "visit", "detector"),
163 )
164
165 def __init__(self, *, config=None):
166 super().__init__(config=config)
167 if config.optional_outputs is None or "psf_stars" not in config.optional_outputs:
168 del self.psf_stars
169 if config.optional_outputs is None or "psf_stars_footprints" not in config.optional_outputs:
170 del self.psf_stars_footprints
171 if config.optional_outputs is None or "astrometry_matches" not in config.optional_outputs:
172 del self.astrometry_matches
173 if config.optional_outputs is None or "photometry_matches" not in config.optional_outputs:
174 del self.photometry_matches
175 if not config.do_calibrate_pixels:
176 del self.applied_photo_calib
177
178
179class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=CalibrateImageConnections):
180 optional_outputs = pexConfig.ListField(
181 doc="Which optional outputs to save (as their connection name)?"
182 " If None, do not output any of these datasets.",
183 dtype=str,
184 # TODO: note somewhere to disable this for benchmarking, but should
185 # we always have it on for production runs?
186 default=["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches"],
187 optional=True
188 )
189
190 # To generate catalog ids consistently across subtasks.
191 id_generator = lsst.meas.base.DetectorVisitIdGeneratorConfig.make_field()
192
193 snap_combine = pexConfig.ConfigurableField(
195 doc="Task to combine two snaps to make one exposure.",
196 )
197
198 # subtasks used during psf characterization
199 install_simple_psf = pexConfig.ConfigurableField(
201 doc="Task to install a simple PSF model into the input exposure to use "
202 "when detecting bright sources for PSF estimation.",
203 )
204 psf_repair = pexConfig.ConfigurableField(
205 target=repair.RepairTask,
206 doc="Task to repair cosmic rays on the exposure before PSF determination.",
207 )
208 psf_subtract_background = pexConfig.ConfigurableField(
210 doc="Task to perform intial background subtraction, before first detection pass.",
211 )
212 psf_detection = pexConfig.ConfigurableField(
214 doc="Task to detect sources for PSF determination."
215 )
216 psf_source_measurement = pexConfig.ConfigurableField(
218 doc="Task to measure sources to be used for psf estimation."
219 )
220 psf_measure_psf = pexConfig.ConfigurableField(
222 doc="Task to measure the psf on bright sources."
223 )
224 psf_normalized_calibration_flux = pexConfig.ConfigurableField(
226 doc="Task to normalize the calibration flux (e.g. compensated tophats) "
227 "for the bright stars used for psf estimation.",
228 )
229
230 # TODO DM-39203: we can remove aperture correction from this task once we are
231 # using the shape-based star/galaxy code.
232 measure_aperture_correction = pexConfig.ConfigurableField(
234 doc="Task to compute the aperture correction from the bright stars."
235 )
236
237 # subtasks used during star measurement
238 star_detection = pexConfig.ConfigurableField(
240 doc="Task to detect stars to return in the output catalog."
241 )
242 star_sky_sources = pexConfig.ConfigurableField(
244 doc="Task to generate sky sources ('empty' regions where there are no detections).",
245 )
246 star_deblend = pexConfig.ConfigurableField(
248 doc="Split blended sources into their components."
249 )
250 star_measurement = pexConfig.ConfigurableField(
252 doc="Task to measure stars to return in the output catalog."
253 )
254 star_normalized_calibration_flux = pexConfig.ConfigurableField(
256 doc="Task to apply the normalization for calibration fluxes (e.g. compensated tophats) "
257 "for the final output star catalog.",
258 )
259 star_apply_aperture_correction = pexConfig.ConfigurableField(
261 doc="Task to apply aperture corrections to the selected stars."
262 )
263 star_catalog_calculation = pexConfig.ConfigurableField(
265 doc="Task to compute extendedness values on the star catalog, "
266 "for the star selector to remove extended sources."
267 )
268 star_set_primary_flags = pexConfig.ConfigurableField(
270 doc="Task to add isPrimary to the catalog."
271 )
272 star_selector = lsst.meas.algorithms.sourceSelectorRegistry.makeField(
273 default="science",
274 doc="Task to select reliable stars to use for calibration."
275 )
276
277 # final calibrations and statistics
278 astrometry = pexConfig.ConfigurableField(
280 doc="Task to perform astrometric calibration to fit a WCS.",
281 )
282 astrometry_ref_loader = pexConfig.ConfigField(
284 doc="Configuration of reference object loader for astrometric fit.",
285 )
286 photometry = pexConfig.ConfigurableField(
288 doc="Task to perform photometric calibration to fit a PhotoCalib.",
289 )
290 photometry_ref_loader = pexConfig.ConfigField(
292 doc="Configuration of reference object loader for photometric fit.",
293 )
294
295 compute_summary_stats = pexConfig.ConfigurableField(
297 doc="Task to to compute summary statistics on the calibrated exposure."
298 )
299
300 do_calibrate_pixels = pexConfig.Field(
301 dtype=bool,
302 default=True,
303 doc=(
304 "If True, apply the photometric calibration to the image pixels "
305 "and background model, and attach an identity PhotoCalib to "
306 "the output image to reflect this. If False`, leave the image "
307 "and background uncalibrated and attach the PhotoCalib that maps "
308 "them to physical units."
309 )
310 )
311
312 def setDefaults(self):
313 super().setDefaults()
314
315 # Use a very broad PSF here, to throughly reject CRs.
316 # TODO investigation: a large initial psf guess may make stars look
317 # like CRs for very good seeing images.
318 self.install_simple_psf.fwhm = 4
319
320 # S/N>=50 sources for PSF determination, but detection to S/N=5.
321 # The thresholdValue sets the minimum flux in a pixel to be included in the
322 # footprint, while peaks are only detected when they are above
323 # thresholdValue * includeThresholdMultiplier. The low thresholdValue
324 # ensures that the footprints are large enough for the noise replacer
325 # to mask out faint undetected neighbors that are not to be measured.
326 self.psf_detection.thresholdValue = 5.0
327 self.psf_detection.includeThresholdMultiplier = 10.0
328 # TODO investigation: Probably want False here, but that may require
329 # tweaking the background spatial scale, to make it small enough to
330 # prevent extra peaks in the wings of bright objects.
331 self.psf_detection.doTempLocalBackground = False
332 # NOTE: we do want reEstimateBackground=True in psf_detection, so that
333 # each measurement step is done with the best background available.
334
335 # Minimal measurement plugins for PSF determination.
336 # TODO DM-39203: We can drop GaussianFlux and PsfFlux, if we use
337 # shapeHSM/moments for star/galaxy separation.
338 # TODO DM-39203: we can remove aperture correction from this task once
339 # we are using the shape-based star/galaxy code.
340 self.psf_source_measurement.plugins = ["base_PixelFlags",
341 "base_SdssCentroid",
342 "ext_shapeHSM_HsmSourceMoments",
343 "base_CircularApertureFlux",
344 "base_GaussianFlux",
345 "base_PsfFlux",
346 "base_CompensatedTophatFlux",
347 ]
348 self.psf_source_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
349 # Only measure apertures we need for PSF measurement.
350 self.psf_source_measurement.plugins["base_CircularApertureFlux"].radii = [12.0]
351 self.psf_source_measurement.plugins["base_CompensatedTophatFlux"].apertures = [12]
352 # TODO DM-40843: Remove this line once this is the psfex default.
353 self.psf_measure_psf.psfDeterminer["psfex"].photometricFluxField = \
354 "base_CircularApertureFlux_12_0_instFlux"
355
356 # No extendeness information available: we need the aperture
357 # corrections to determine that.
358 self.measure_aperture_correction.sourceSelector["science"].doUnresolved = False
359 self.measure_aperture_correction.sourceSelector["science"].flags.good = ["calib_psf_used"]
360 self.measure_aperture_correction.sourceSelector["science"].flags.bad = []
361
362 # Detection for good S/N for astrometry/photometry and other
363 # downstream tasks; detection mask to S/N>=5, but S/N>=10 peaks.
364 self.star_detection.thresholdValue = 5.0
365 self.star_detection.includeThresholdMultiplier = 2.0
366 self.star_measurement.plugins = ["base_PixelFlags",
367 "base_SdssCentroid",
368 "ext_shapeHSM_HsmSourceMoments",
369 "ext_shapeHSM_HsmPsfMoments",
370 "base_GaussianFlux",
371 "base_PsfFlux",
372 "base_CircularApertureFlux",
373 "base_ClassificationSizeExtendedness",
374 "base_CompensatedTophatFlux",
375 ]
376 self.star_measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
377 self.star_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
378 # Only measure the apertures we need for star selection.
379 self.star_measurement.plugins["base_CircularApertureFlux"].radii = [12.0]
380 self.star_measurement.plugins["base_CompensatedTophatFlux"].apertures = [12]
381
382 # We measure and apply the normalization aperture correction with the
383 # psf_normalized_calibration_flux task, and we only apply the normalization
384 # aperture correction for the full list of stars.
385 self.star_normalized_calibration_flux.do_measure_ap_corr = False
386
387 # Select stars with reliable measurements and no bad flags.
388 self.star_selector["science"].doFlags = True
389 self.star_selector["science"].doUnresolved = True
390 self.star_selector["science"].doSignalToNoise = True
391 self.star_selector["science"].signalToNoise.minimum = 10.0
392 # Keep sky sources in the output catalog, even though they aren't
393 # wanted for calibration.
394 self.star_selector["science"].doSkySources = True
395
396 # Use the affine WCS fitter (assumes we have a good camera geometry).
397 self.astrometry.wcsFitter.retarget(lsst.meas.astrom.FitAffineWcsTask)
398 # phot_g_mean is the primary Gaia band for all input bands.
399 self.astrometry_ref_loader.anyFilterMapsToThis = "phot_g_mean"
400
401 # Only reject sky sources; we already selected good stars.
402 self.astrometry.sourceSelector["science"].doFlags = True
403 self.astrometry.sourceSelector["science"].flags.bad = ["sky_source"]
404 self.photometry.match.sourceSelection.doFlags = True
405 self.photometry.match.sourceSelection.flags.bad = ["sky_source"]
406 # Unset the (otherwise reasonable, but we've already made the
407 # selections we want above) selection settings in PhotoCalTask.
408 self.photometry.match.sourceSelection.doRequirePrimary = False
409 self.photometry.match.sourceSelection.doUnresolved = False
410
411 # All sources should be good for PSF summary statistics.
412 # TODO: These should both be changed to calib_psf_used with DM-41640.
413 self.compute_summary_stats.starSelection = "calib_photometry_used"
414 self.compute_summary_stats.starSelector.flags.good = ["calib_photometry_used"]
415
416 def validate(self):
417 super().validate()
418
419 # Ensure that the normalization calibration flux tasks
420 # are configured correctly.
421 if not self.psf_normalized_calibration_flux.do_measure_ap_corr:
422 msg = ("psf_normalized_calibration_flux task must be configured with do_measure_ap_corr=True "
423 "or else the normalization and calibration flux will not be properly measured.")
424 raise pexConfig.FieldValidationError(
425 CalibrateImageConfig.psf_normalized_calibration_flux, self, msg,
426 )
427 if self.star_normalized_calibration_flux.do_measure_ap_corr:
428 msg = ("star_normalized_calibration_flux task must be configured with do_measure_ap_corr=False "
429 "to apply the previously measured normalization to the full catalog of calibration "
430 "fluxes.")
431 raise pexConfig.FieldValidationError(
432 CalibrateImageConfig.star_normalized_calibration_flux, self, msg,
433 )
434
435 # Ensure base_LocalPhotoCalib and base_LocalWcs plugins are not run,
436 # because they'd be running too early to pick up the fitted PhotoCalib
437 # and WCS.
438 if "base_LocalWcs" in self.psf_source_measurement.plugins.names:
439 raise pexConfig.FieldValidationError(
440 CalibrateImageConfig.psf_source_measurement,
441 self,
442 "base_LocalWcs cannot run CalibrateImageTask, as it would be run before the astrometry fit."
443 )
444 if "base_LocalWcs" in self.star_measurement.plugins.names:
445 raise pexConfig.FieldValidationError(
446 CalibrateImageConfig.star_measurement,
447 self,
448 "base_LocalWcs cannot run CalibrateImageTask, as it would be run before the astrometry fit."
449 )
450 if "base_LocalPhotoCalib" in self.psf_source_measurement.plugins.names:
451 raise pexConfig.FieldValidationError(
452 CalibrateImageConfig.psf_source_measurement,
453 self,
454 "base_LocalPhotoCalib cannot run CalibrateImageTask, "
455 "as it would be run before the photometry fit."
456 )
457 if "base_LocalPhotoCalib" in self.star_measurement.plugins.names:
458 raise pexConfig.FieldValidationError(
459 CalibrateImageConfig.star_measurement,
460 self,
461 "base_LocalPhotoCalib cannot run CalibrateImageTask, "
462 "as it would be run before the photometry fit."
463 )
464
465
466class CalibrateImageTask(pipeBase.PipelineTask):
467 """Compute the PSF, aperture corrections, astrometric and photometric
468 calibrations, and summary statistics for a single science exposure, and
469 produce a catalog of brighter stars that were used to calibrate it.
470
471 Parameters
472 ----------
473 initial_stars_schema : `lsst.afw.table.Schema`
474 Schema of the initial_stars output catalog.
475 """
476 _DefaultName = "calibrateImage"
477 ConfigClass = CalibrateImageConfig
478
479 def __init__(self, initial_stars_schema=None, **kwargs):
480 super().__init__(**kwargs)
481
482 self.makeSubtask("snap_combine")
483
484 # PSF determination subtasks
485 self.makeSubtask("install_simple_psf")
486 self.makeSubtask("psf_repair")
487 self.makeSubtask("psf_subtract_background")
488 self.psf_schema = afwTable.SourceTable.makeMinimalSchema()
489 self.makeSubtask("psf_detection", schema=self.psf_schema)
490 self.makeSubtask("psf_source_measurement", schema=self.psf_schema)
491 self.makeSubtask("psf_measure_psf", schema=self.psf_schema)
492 self.makeSubtask("psf_normalized_calibration_flux", schema=self.psf_schema)
493
494 self.makeSubtask("measure_aperture_correction", schema=self.psf_schema)
495
496 # star measurement subtasks
497 if initial_stars_schema is None:
498 initial_stars_schema = afwTable.SourceTable.makeMinimalSchema()
499
500 # These fields let us track which sources were used for psf and
501 # aperture correction calculations.
502 self.psf_fields = ("calib_psf_candidate", "calib_psf_used", "calib_psf_reserved",
503 # TODO DM-39203: these can be removed once apcorr is gone.
504 "apcorr_slot_CalibFlux_used", "apcorr_base_GaussianFlux_used",
505 "apcorr_base_PsfFlux_used")
506 for field in self.psf_fields:
507 item = self.psf_schema.find(field)
508 initial_stars_schema.addField(item.getField())
509
510 afwTable.CoordKey.addErrorFields(initial_stars_schema)
511 self.makeSubtask("star_detection", schema=initial_stars_schema)
512 self.makeSubtask("star_sky_sources", schema=initial_stars_schema)
513 self.makeSubtask("star_deblend", schema=initial_stars_schema)
514 self.makeSubtask("star_measurement", schema=initial_stars_schema)
515 self.makeSubtask("star_normalized_calibration_flux", schema=initial_stars_schema)
516
517 self.makeSubtask("star_apply_aperture_correction", schema=initial_stars_schema)
518 self.makeSubtask("star_catalog_calculation", schema=initial_stars_schema)
519 self.makeSubtask("star_set_primary_flags", schema=initial_stars_schema, isSingleFrame=True)
520 self.makeSubtask("star_selector")
521
522 self.makeSubtask("astrometry", schema=initial_stars_schema)
523 self.makeSubtask("photometry", schema=initial_stars_schema)
524
525 self.makeSubtask("compute_summary_stats")
526
527 # For the butler to persist it.
528 self.initial_stars_schema = afwTable.SourceCatalog(initial_stars_schema)
529
530 def runQuantum(self, butlerQC, inputRefs, outputRefs):
531 inputs = butlerQC.get(inputRefs)
532 exposures = inputs.pop("exposures")
533
534 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId)
535
537 dataIds=[ref.datasetRef.dataId for ref in inputRefs.astrometry_ref_cat],
538 refCats=inputs.pop("astrometry_ref_cat"),
539 name=self.config.connections.astrometry_ref_cat,
540 config=self.config.astrometry_ref_loader, log=self.log)
541 self.astrometry.setRefObjLoader(astrometry_loader)
542
544 dataIds=[ref.datasetRef.dataId for ref in inputRefs.photometry_ref_cat],
545 refCats=inputs.pop("photometry_ref_cat"),
546 name=self.config.connections.photometry_ref_cat,
547 config=self.config.photometry_ref_loader, log=self.log)
548 self.photometry.match.setRefObjLoader(photometry_loader)
549
550 # This should not happen with a properly configured execution context.
551 assert not inputs, "runQuantum got more inputs than expected"
552
553 # Specify the fields that `annotate` needs below, to ensure they
554 # exist, even as None.
555 result = pipeBase.Struct(exposure=None,
556 stars_footprints=None,
557 psf_stars_footprints=None,
558 )
559 try:
560 self.run(exposures=exposures, result=result, id_generator=id_generator)
561 except pipeBase.AlgorithmError as e:
562 error = pipeBase.AnnotatedPartialOutputsError.annotate(
563 e,
564 self,
565 result.exposure,
566 result.psf_stars_footprints,
567 result.stars_footprints,
568 log=self.log
569 )
570 butlerQC.put(result, outputRefs)
571 raise error from e
572
573 butlerQC.put(result, outputRefs)
574
575 @timeMethod
576 def run(self, *, exposures, id_generator=None, result=None):
577 """Find stars and perform psf measurement, then do a deeper detection
578 and measurement and calibrate astrometry and photometry from that.
579
580 Parameters
581 ----------
582 exposures : `lsst.afw.image.Exposure` or `list` [`lsst.afw.image.Exposure`]
583 Post-ISR exposure(s), with an initial WCS, VisitInfo, and Filter.
584 Modified in-place during processing if only one is passed.
585 If two exposures are passed, treat them as snaps and combine
586 before doing further processing.
587 id_generator : `lsst.meas.base.IdGenerator`, optional
588 Object that generates source IDs and provides random seeds.
589 result : `lsst.pipe.base.Struct`, optional
590 Result struct that is modified to allow saving of partial outputs
591 for some failure conditions. If the task completes successfully,
592 this is also returned.
593
594 Returns
595 -------
596 result : `lsst.pipe.base.Struct`
597 Results as a struct with attributes:
598
599 ``exposure``
600 Calibrated exposure, with pixels in nJy units.
601 (`lsst.afw.image.Exposure`)
602 ``stars``
603 Stars that were used to calibrate the exposure, with
604 calibrated fluxes and magnitudes.
605 (`astropy.table.Table`)
606 ``stars_footprints``
607 Footprints of stars that were used to calibrate the exposure.
608 (`lsst.afw.table.SourceCatalog`)
609 ``psf_stars``
610 Stars that were used to determine the image PSF.
611 (`astropy.table.Table`)
612 ``psf_stars_footprints``
613 Footprints of stars that were used to determine the image PSF.
614 (`lsst.afw.table.SourceCatalog`)
615 ``background``
616 Background that was fit to the exposure when detecting
617 ``stars``. (`lsst.afw.math.BackgroundList`)
618 ``applied_photo_calib``
619 Photometric calibration that was fit to the star catalog and
620 applied to the exposure. (`lsst.afw.image.PhotoCalib`)
621 This is `None` if ``config.do_calibrate_pixels`` is `False`.
622 ``astrometry_matches``
623 Reference catalog stars matches used in the astrometric fit.
624 (`list` [`lsst.afw.table.ReferenceMatch`] or `lsst.afw.table.BaseCatalog`)
625 ``photometry_matches``
626 Reference catalog stars matches used in the photometric fit.
627 (`list` [`lsst.afw.table.ReferenceMatch`] or `lsst.afw.table.BaseCatalog`)
628 """
629 if result is None:
630 result = pipeBase.Struct()
631 if id_generator is None:
632 id_generator = lsst.meas.base.IdGenerator()
633
634 result.exposure = self.snap_combine.run(exposures).exposure
635 self._recordMaskedPixelFractions(result.exposure)
636
637 result.psf_stars_footprints, result.background, candidates = self._compute_psf(result.exposure,
638 id_generator)
639 self._measure_aperture_correction(result.exposure, result.psf_stars_footprints)
640
641 result.psf_stars = result.psf_stars_footprints.asAstropy()
642
643 result.stars_footprints = self._find_stars(result.exposure, result.background, id_generator)
644 self._match_psf_stars(result.psf_stars_footprints, result.stars_footprints)
645 result.stars = result.stars_footprints.asAstropy()
646 self.metadata["star_count"] = np.sum(~result.stars["sky_source"])
647
648 astrometry_matches, astrometry_meta = self._fit_astrometry(result.exposure, result.stars_footprints)
649 self.metadata["astrometry_matches_count"] = len(astrometry_matches)
650 if self.config.optional_outputs is not None and "astrometry_matches" in self.config.optional_outputs:
651 result.astrometry_matches = lsst.meas.astrom.denormalizeMatches(astrometry_matches,
652 astrometry_meta)
653
654 result.stars_footprints, photometry_matches, \
655 photometry_meta, photo_calib = self._fit_photometry(result.exposure, result.stars_footprints)
656 self.metadata["photometry_matches_count"] = len(photometry_matches)
657 # fit_photometry returns a new catalog, so we need a new astropy table view.
658 result.stars = result.stars_footprints.asAstropy()
659 if self.config.optional_outputs is not None and "photometry_matches" in self.config.optional_outputs:
660 result.photometry_matches = lsst.meas.astrom.denormalizeMatches(photometry_matches,
661 photometry_meta)
662
663 self._summarize(result.exposure, result.stars_footprints, result.background)
664 if self.config.do_calibrate_pixels:
665 self._apply_photometry(result.exposure, result.background)
666 result.applied_photo_calib = photo_calib
667 else:
668 result.applied_photo_calib = None
669 return result
670
671 def _compute_psf(self, exposure, id_generator):
672 """Find bright sources detected on an exposure and fit a PSF model to
673 them, repairing likely cosmic rays before detection.
674
675 Repair, detect, measure, and compute PSF twice, to ensure the PSF
676 model does not include contributions from cosmic rays.
677
678 Parameters
679 ----------
680 exposure : `lsst.afw.image.Exposure`
681 Exposure to detect and measure bright stars on.
682 id_generator : `lsst.meas.base.IdGenerator`, optional
683 Object that generates source IDs and provides random seeds.
684
685 Returns
686 -------
687 sources : `lsst.afw.table.SourceCatalog`
688 Catalog of detected bright sources.
689 background : `lsst.afw.math.BackgroundList`
690 Background that was fit to the exposure during detection.
691 cell_set : `lsst.afw.math.SpatialCellSet`
692 PSF candidates returned by the psf determiner.
693 """
694 def log_psf(msg, addToMetadata=False):
695 """Log the parameters of the psf and background, with a prepended
696 message. There is also the option to add the PSF sigma to the task
697 metadata.
698
699 Parameters
700 ----------
701 msg : `str`
702 Message to prepend the log info with.
703 addToMetadata : `bool`, optional
704 Whether to add the final psf sigma value to the task metadata
705 (the default is False).
706 """
707 position = exposure.psf.getAveragePosition()
708 sigma = exposure.psf.computeShape(position).getDeterminantRadius()
709 dimensions = exposure.psf.computeImage(position).getDimensions()
710 median_background = np.median(background.getImage().array)
711 self.log.info("%s sigma=%0.4f, dimensions=%s; median background=%0.2f",
712 msg, sigma, dimensions, median_background)
713 if addToMetadata:
714 self.metadata["final_psf_sigma"] = sigma
715
716 self.log.info("First pass detection with Guassian PSF FWHM=%s pixels",
717 self.config.install_simple_psf.fwhm)
718 self.install_simple_psf.run(exposure=exposure)
719
720 background = self.psf_subtract_background.run(exposure=exposure).background
721 log_psf("Initial PSF:")
722 self.psf_repair.run(exposure=exposure, keepCRs=True)
723
724 table = afwTable.SourceTable.make(self.psf_schema, id_generator.make_table_id_factory())
725 # Re-estimate the background during this detection step, so that
726 # measurement uses the most accurate background-subtraction.
727 detections = self.psf_detection.run(table=table, exposure=exposure, background=background)
728 self.metadata["initial_psf_positive_footprint_count"] = detections.numPos
729 self.metadata["initial_psf_negative_footprint_count"] = detections.numNeg
730 self.metadata["initial_psf_positive_peak_count"] = detections.numPosPeaks
731 self.metadata["initial_psf_negative_peak_count"] = detections.numNegPeaks
732 self.psf_source_measurement.run(detections.sources, exposure)
733 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources)
734 # Replace the initial PSF with something simpler for the second
735 # repair/detect/measure/measure_psf step: this can help it converge.
736 self.install_simple_psf.run(exposure=exposure)
737
738 log_psf("Rerunning with simple PSF:")
739 # TODO investigation: Should we only re-run repair here, to use the
740 # new PSF? Maybe we *do* need to re-run measurement with PsfFlux, to
741 # use the fitted PSF?
742 # TODO investigation: do we need a separate measurement task here
743 # for the post-psf_measure_psf step, since we only want to do PsfFlux
744 # and GaussianFlux *after* we have a PSF? Maybe that's not relevant
745 # once DM-39203 is merged?
746 self.psf_repair.run(exposure=exposure, keepCRs=True)
747 # Re-estimate the background during this detection step, so that
748 # measurement uses the most accurate background-subtraction.
749 detections = self.psf_detection.run(table=table, exposure=exposure, background=background)
750 self.metadata["simple_psf_positive_footprint_count"] = detections.numPos
751 self.metadata["simple_psf_negative_footprint_count"] = detections.numNeg
752 self.metadata["simple_psf_positive_peak_count"] = detections.numPosPeaks
753 self.metadata["simple_psf_negative_peak_count"] = detections.numNegPeaks
754 self.psf_source_measurement.run(detections.sources, exposure)
755 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources)
756
757 log_psf("Final PSF:", addToMetadata=True)
758
759 # Final repair with final PSF, removing cosmic rays this time.
760 self.psf_repair.run(exposure=exposure)
761 # Final measurement with the CRs removed.
762 self.psf_source_measurement.run(detections.sources, exposure)
763
764 # PSF is set on exposure; candidates are returned to use for
765 # calibration flux normalization and aperture corrections.
766 return detections.sources, background, psf_result.cellSet
767
768 def _measure_aperture_correction(self, exposure, bright_sources):
769 """Measure and set the ApCorrMap on the Exposure, using
770 previously-measured bright sources.
771
772 This function first normalizes the calibration flux and then
773 the full set of aperture corrections are measured relative
774 to this normalized calibration flux.
775
776 Parameters
777 ----------
778 exposure : `lsst.afw.image.Exposure`
779 Exposure to set the ApCorrMap on.
780 bright_sources : `lsst.afw.table.SourceCatalog`
781 Catalog of detected bright sources; modified to include columns
782 necessary for point source determination for the aperture correction
783 calculation.
784 """
785 norm_ap_corr_map = self.psf_normalized_calibration_flux.run(
786 exposure=exposure,
787 catalog=bright_sources,
788 ).ap_corr_map
789
790 ap_corr_map = self.measure_aperture_correction.run(exposure, bright_sources).apCorrMap
791
792 # Need to merge the aperture correction map from the normalization.
793 for key in norm_ap_corr_map:
794 ap_corr_map[key] = norm_ap_corr_map[key]
795
796 exposure.info.setApCorrMap(ap_corr_map)
797
798 def _find_stars(self, exposure, background, id_generator):
799 """Detect stars on an exposure that has a PSF model, and measure their
800 PSF, circular aperture, compensated gaussian fluxes.
801
802 Parameters
803 ----------
804 exposure : `lsst.afw.image.Exposure`
805 Exposure to detect and measure stars on.
806 background : `lsst.afw.math.BackgroundList`
807 Background that was fit to the exposure during detection;
808 modified in-place during subsequent detection.
809 id_generator : `lsst.meas.base.IdGenerator`
810 Object that generates source IDs and provides random seeds.
811
812 Returns
813 -------
814 stars : `SourceCatalog`
815 Sources that are very likely to be stars, with a limited set of
816 measurements performed on them.
817 """
818 table = afwTable.SourceTable.make(self.initial_stars_schema.schema,
819 id_generator.make_table_id_factory())
820 # Re-estimate the background during this detection step, so that
821 # measurement uses the most accurate background-subtraction.
822 detections = self.star_detection.run(table=table, exposure=exposure, background=background)
823 sources = detections.sources
824 self.star_sky_sources.run(exposure.mask, id_generator.catalog_id, sources)
825
826 # TODO investigation: Could this deblender throw away blends of non-PSF sources?
827 self.star_deblend.run(exposure=exposure, sources=sources)
828 # The deblender may not produce a contiguous catalog; ensure
829 # contiguity for subsequent tasks.
830 if not sources.isContiguous():
831 sources = sources.copy(deep=True)
832
833 # Measure everything, and use those results to select only stars.
834 self.star_measurement.run(sources, exposure)
835 self.metadata["post_deblend_source_count"] = np.sum(~sources["sky_source"])
836 self.metadata["saturated_source_count"] = np.sum(sources["base_PixelFlags_flag_saturated"])
837 self.metadata["bad_source_count"] = np.sum(sources["base_PixelFlags_flag_bad"])
838
839 # Run the normalization calibration flux task to apply the
840 # normalization correction to create normalized
841 # calibration fluxes.
842 self.star_normalized_calibration_flux.run(exposure=exposure, catalog=sources)
843 self.star_apply_aperture_correction.run(sources, exposure.apCorrMap)
844 self.star_catalog_calculation.run(sources)
845 self.star_set_primary_flags.run(sources)
846
847 result = self.star_selector.run(sources)
848 # The star selector may not produce a contiguous catalog.
849 if not result.sourceCat.isContiguous():
850 return result.sourceCat.copy(deep=True)
851 else:
852 return result.sourceCat
853
854 def _match_psf_stars(self, psf_stars, stars):
855 """Match calibration stars to psf stars, to identify which were psf
856 candidates, and which were used or reserved during psf measurement.
857
858 Parameters
859 ----------
860 psf_stars : `lsst.afw.table.SourceCatalog`
861 PSF candidate stars that were sent to the psf determiner. Used to
862 populate psf-related flag fields.
863 stars : `lsst.afw.table.SourceCatalog`
864 Stars that will be used for calibration; psf-related fields will
865 be updated in-place.
866
867 Notes
868 -----
869 This code was adapted from CalibrateTask.copyIcSourceFields().
870 """
871 control = afwTable.MatchControl()
872 # Return all matched objects, to separate blends.
873 control.findOnlyClosest = False
874 matches = afwTable.matchXy(psf_stars, stars, 3.0, control)
875 deblend_key = stars.schema["deblend_nChild"].asKey()
876 matches = [m for m in matches if m[1].get(deblend_key) == 0]
877
878 # Because we had to allow multiple matches to handle parents, we now
879 # need to prune to the best (closest) matches.
880 # Closest matches is a dict of psf_stars source ID to Match record
881 # (psf_stars source, sourceCat source, distance in pixels).
882 best = {}
883 for match_psf, match_stars, d in matches:
884 match = best.get(match_psf.getId())
885 if match is None or d <= match[2]:
886 best[match_psf.getId()] = (match_psf, match_stars, d)
887 matches = list(best.values())
888 # We'll use this to construct index arrays into each catalog.
889 ids = np.array([(match_psf.getId(), match_stars.getId()) for match_psf, match_stars, d in matches]).T
890
891 if (n_matches := len(matches)) == 0:
892 raise NoPsfStarsToStarsMatchError(n_psf_stars=len(psf_stars), n_stars=len(stars))
893
894 self.log.info("%d psf stars out of %d matched %d calib stars", n_matches, len(psf_stars), len(stars))
895 self.metadata["matched_psf_star_count"] = n_matches
896
897 # Check that no stars sources are listed twice; we already know
898 # that each match has a unique psf_stars id, due to using as the key
899 # in best above.
900 n_unique = len(set(m[1].getId() for m in matches))
901 if n_unique != n_matches:
902 self.log.warning("%d psf_stars matched only %d stars", n_matches, n_unique)
903
904 # The indices of the IDs, so we can update the flag fields as arrays.
905 idx_psf_stars = np.searchsorted(psf_stars["id"], ids[0])
906 idx_stars = np.searchsorted(stars["id"], ids[1])
907 for field in self.psf_fields:
908 result = np.zeros(len(stars), dtype=bool)
909 result[idx_stars] = psf_stars[field][idx_psf_stars]
910 stars[field] = result
911
912 def _fit_astrometry(self, exposure, stars):
913 """Fit an astrometric model to the data and return the reference
914 matches used in the fit, and the fitted WCS.
915
916 Parameters
917 ----------
918 exposure : `lsst.afw.image.Exposure`
919 Exposure that is being fit, to get PSF and other metadata from.
920 Modified to add the fitted skyWcs.
921 stars : `SourceCatalog`
922 Good stars selected for use in calibration, with RA/Dec coordinates
923 computed from the pixel positions and fitted WCS.
924
925 Returns
926 -------
927 matches : `list` [`lsst.afw.table.ReferenceMatch`]
928 Reference/stars matches used in the fit.
929 """
930 result = self.astrometry.run(stars, exposure)
931 return result.matches, result.matchMeta
932
933 def _fit_photometry(self, exposure, stars):
934 """Fit a photometric model to the data and return the reference
935 matches used in the fit, and the fitted PhotoCalib.
936
937 Parameters
938 ----------
939 exposure : `lsst.afw.image.Exposure`
940 Exposure that is being fit, to get PSF and other metadata from.
941 Has the fit `lsst.afw.image.PhotoCalib` attached, with pixel values
942 unchanged.
943 stars : `lsst.afw.table.SourceCatalog`
944 Good stars selected for use in calibration.
945
946 Returns
947 -------
948 calibrated_stars : `lsst.afw.table.SourceCatalog`
949 Star catalog with flux/magnitude columns computed from the fitted
950 photoCalib (instFlux columns are retained as well).
951 matches : `list` [`lsst.afw.table.ReferenceMatch`]
952 Reference/stars matches used in the fit.
953 matchMeta : `lsst.daf.base.PropertyList`
954 Metadata needed to unpersist matches, as returned by the matcher.
955 photo_calib : `lsst.afw.image.PhotoCalib`
956 Photometric calibration that was fit to the star catalog.
957 """
958 result = self.photometry.run(exposure, stars)
959 calibrated_stars = result.photoCalib.calibrateCatalog(stars)
960 exposure.setPhotoCalib(result.photoCalib)
961 return calibrated_stars, result.matches, result.matchMeta, result.photoCalib
962
963 def _apply_photometry(self, exposure, background):
964 """Apply the photometric model attached to the exposure to the
965 exposure's pixels and an associated background model.
966
967 Parameters
968 ----------
969 exposure : `lsst.afw.image.Exposure`
970 Exposure with the target `lsst.afw.image.PhotoCalib` attached.
971 On return, pixel values will be calibrated and an identity
972 photometric transform will be attached.
973 background : `lsst.afw.math.BackgroundList`
974 Background model to convert to nanojansky units in place.
975 """
976 photo_calib = exposure.getPhotoCalib()
977 exposure.maskedImage = photo_calib.calibrateImage(exposure.maskedImage)
978 identity = afwImage.PhotoCalib(1.0,
979 photo_calib.getCalibrationErr(),
980 bbox=exposure.getBBox())
981 exposure.setPhotoCalib(identity)
982 exposure.metadata["BUNIT"] = "nJy"
983
984 assert photo_calib._isConstant, \
985 "Background calibration assumes a constant PhotoCalib; PhotoCalTask should always return that."
986 for bg in background:
987 # The statsImage is a view, but we can't assign to a function call in python.
988 binned_image = bg[0].getStatsImage()
989 binned_image *= photo_calib.getCalibrationMean()
990
991 def _summarize(self, exposure, stars, background):
992 """Compute summary statistics on the exposure and update in-place the
993 calibrations attached to it.
994
995 Parameters
996 ----------
997 exposure : `lsst.afw.image.Exposure`
998 Exposure that was calibrated, to get PSF and other metadata from.
999 Should be in instrumental units with the photometric calibration
1000 attached.
1001 Modified to contain the computed summary statistics.
1002 stars : `SourceCatalog`
1003 Good stars selected used in calibration.
1004 background : `lsst.afw.math.BackgroundList`
1005 Background that was fit to the exposure during detection of the
1006 above stars. Should be in instrumental units.
1007 """
1008 summary = self.compute_summary_stats.run(exposure, stars, background)
1009 exposure.info.setSummaryStats(summary)
1010
1011 def _recordMaskedPixelFractions(self, exposure):
1012 """Record the fraction of all the pixels in an exposure
1013 that are masked with a given flag. Each fraction is
1014 recorded in the task metadata. One record per flag type.
1015
1016 Parameters
1017 ----------
1018 exposure : `lsst.afw.image.ExposureF`
1019 The target exposure to calculate masked pixel fractions for.
1020 """
1021
1022 mask = exposure.mask
1023 maskPlanes = list(mask.getMaskPlaneDict().keys())
1024 for maskPlane in maskPlanes:
1025 self.metadata[f"{maskPlane.lower()}_mask_fraction"] = (
1026 evaluateMaskFraction(mask, maskPlane)
1027 )
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)
run(self, *exposures, id_generator=None, result=None)
_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)
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