LSST Applications g00274db5b6+edbf708997,g00d0e8bbd7+edbf708997,g199a45376c+5137f08352,g1fd858c14a+1d4b6db739,g262e1987ae+f4d9505c4f,g29ae962dfc+7156fb1a53,g2cef7863aa+73c82f25e4,g35bb328faa+edbf708997,g3e17d7035e+5b3adc59f5,g3fd5ace14f+852fa6fbcb,g47891489e3+6dc8069a4c,g53246c7159+edbf708997,g64539dfbff+9f17e571f4,g67b6fd64d1+6dc8069a4c,g74acd417e5+ae494d68d9,g786e29fd12+af89c03590,g7ae74a0b1c+a25e60b391,g7aefaa3e3d+536efcc10a,g7cc15d900a+d121454f8d,g87389fa792+a4172ec7da,g89139ef638+6dc8069a4c,g8d7436a09f+28c28d8d6d,g8ea07a8fe4+db21c37724,g92c671f44c+9f17e571f4,g98df359435+b2e6376b13,g99af87f6a8+b0f4ad7b8d,gac66b60396+966efe6077,gb88ae4c679+7dec8f19df,gbaa8f7a6c5+38b34f4976,gbf99507273+edbf708997,gc24b5d6ed1+9f17e571f4,gca7fc764a6+6dc8069a4c,gcc769fe2a4+97d0256649,gd7ef33dd92+6dc8069a4c,gdab6d2f7ff+ae494d68d9,gdbb4c4dda9+9f17e571f4,ge410e46f29+6dc8069a4c,geaed405ab2+e194be0d2b,w.2025.47
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 "AllCentroidsFlaggedError"]
24
25import numpy as np
26import requests
27import os
28
29from lsst.afw.geom import SpanSet
30import lsst.afw.table as afwTable
31import lsst.afw.image as afwImage
32import lsst.afw.math as afwMath
33from lsst.ip.diffim.utils import evaluateMaskFraction, populate_sattle_visit_cache
38from lsst.meas.algorithms.adaptive_thresholds import AdaptiveThresholdDetectionTask
39import lsst.meas.base
42import lsst.meas.extensions.shapeHSM
43from lsst.obs.base import createInitialSkyWcs
44import lsst.pex.config as pexConfig
45import lsst.pipe.base as pipeBase
46from lsst.pipe.base import connectionTypes
47from lsst.utils.timer import timeMethod
48
49from . import measurePsf, repair, photoCal, computeExposureSummaryStats, snapCombine, diffractionSpikeMask
50
51
52class AllCentroidsFlaggedError(pipeBase.AlgorithmError):
53 """Raised when there are no valid centroids during psf fitting.
54 """
55 def __init__(self, n_sources, psf_shape_ixx, psf_shape_iyy, psf_shape_ixy, psf_size):
56 msg = (f"All source centroids (out of {n_sources}) flagged during PSF fitting. "
57 "Original image PSF is likely unuseable; best-fit PSF shape parameters: "
58 f"Ixx={psf_shape_ixx}, Iyy={psf_shape_iyy}, Ixy={psf_shape_ixy}, size={psf_size}"
59 )
60 super().__init__(msg)
61 self.n_sources = n_sources
62 self.psf_shape_ixx = psf_shape_ixx
63 self.psf_shape_iyy = psf_shape_iyy
64 self.psf_shape_ixy = psf_shape_ixy
65 self.psf_size = psf_size
66
67 @property
68 def metadata(self):
69 return {"n_sources": self.n_sources,
70 "psf_shape_ixx": self.psf_shape_ixx,
71 "psf_shape_iyy": self.psf_shape_iyy,
72 "psf_shape_ixy": self.psf_shape_ixy,
73 "psf_size": self.psf_size
74 }
75
76
77class NoPsfStarsToStarsMatchError(pipeBase.AlgorithmError):
78 """Raised when there are no matches between the psf_stars and stars
79 catalogs.
80 """
81 def __init__(self, *, n_psf_stars, n_stars):
82 msg = (f"No psf stars out of {n_psf_stars} matched {n_stars} calib stars."
83 " Downstream processes probably won't have useful {calib_type} stars in this case."
84 " Is `star_selector` too strict or is this a bad image?")
85 super().__init__(msg)
86 self.n_psf_stars = n_psf_stars
87 self.n_stars = n_stars
88
89 @property
90 def metadata(self):
91 return {"n_psf_stars": self.n_psf_stars,
92 "n_stars": self.n_stars
93 }
94
95
96class CalibrateImageConnections(pipeBase.PipelineTaskConnections,
97 dimensions=("instrument", "visit", "detector")):
98
99 astrometry_ref_cat = connectionTypes.PrerequisiteInput(
100 doc="Reference catalog to use for astrometric calibration.",
101 name="gaia_dr3_20230707",
102 storageClass="SimpleCatalog",
103 dimensions=("skypix",),
104 deferLoad=True,
105 multiple=True,
106 )
107 photometry_ref_cat = connectionTypes.PrerequisiteInput(
108 doc="Reference catalog to use for photometric calibration.",
109 name="ps1_pv3_3pi_20170110",
110 storageClass="SimpleCatalog",
111 dimensions=("skypix",),
112 deferLoad=True,
113 multiple=True
114 )
115 camera_model = connectionTypes.PrerequisiteInput(
116 doc="Camera distortion model to use for astrometric calibration.",
117 name="astrometry_camera",
118 storageClass="Camera",
119 dimensions=("instrument", "physical_filter"),
120 isCalibration=True,
121 minimum=0, # Can fall back on WCS already attached to exposure.
122 )
123 exposures = connectionTypes.Input(
124 doc="Exposure (or two snaps) to be calibrated, and detected and measured on.",
125 name="postISRCCD",
126 storageClass="Exposure",
127 multiple=True, # to handle 1 exposure or 2 snaps
128 dimensions=["instrument", "exposure", "detector"],
129 )
130
131 background_flat = connectionTypes.PrerequisiteInput(
132 name="flat",
133 doc="Flat calibration frame used for background correction.",
134 storageClass="ExposureF",
135 dimensions=["instrument", "detector", "physical_filter"],
136 isCalibration=True,
137 )
138 illumination_correction = connectionTypes.PrerequisiteInput(
139 name="illuminationCorrection",
140 doc="Illumination correction frame.",
141 storageClass="Exposure",
142 dimensions=["instrument", "detector", "physical_filter"],
143 isCalibration=True,
144 )
145
146 # outputs
147 initial_stars_schema = connectionTypes.InitOutput(
148 doc="Schema of the output initial stars catalog.",
149 name="initial_stars_schema",
150 storageClass="SourceCatalog",
151 )
152
153 # TODO DM-38732: We want some kind of flag on Exposures/Catalogs to make
154 # it obvious which components had failed to be computed/persisted.
155 exposure = connectionTypes.Output(
156 doc="Photometrically calibrated, background-subtracted exposure with fitted calibrations and "
157 "summary statistics. To recover the original exposure, first add the background "
158 "(`initial_pvi_background`), and then uncalibrate (divide by `initial_photoCalib_detector`).",
159 name="initial_pvi",
160 storageClass="ExposureF",
161 dimensions=("instrument", "visit", "detector"),
162 )
163 stars = connectionTypes.Output(
164 doc="Catalog of unresolved sources detected on the calibrated exposure.",
165 name="initial_stars_detector",
166 storageClass="ArrowAstropy",
167 dimensions=["instrument", "visit", "detector"],
168 )
169 stars_footprints = connectionTypes.Output(
170 doc="Catalog of unresolved sources detected on the calibrated exposure; "
171 "includes source footprints.",
172 name="initial_stars_footprints_detector",
173 storageClass="SourceCatalog",
174 dimensions=["instrument", "visit", "detector"],
175 )
176 applied_photo_calib = connectionTypes.Output(
177 doc=(
178 "Photometric calibration that was applied to exposure's pixels. "
179 "This connection is disabled when do_calibrate_pixels=False."
180 ),
181 name="initial_photoCalib_detector",
182 storageClass="PhotoCalib",
183 dimensions=("instrument", "visit", "detector"),
184 )
185 background = connectionTypes.Output(
186 doc="Background models estimated during calibration task; calibrated to be in nJy units.",
187 name="initial_pvi_background",
188 storageClass="Background",
189 dimensions=("instrument", "visit", "detector"),
190 )
191 background_to_photometric_ratio = connectionTypes.Output(
192 doc="Ratio of a background-flattened image to a photometric-flattened image. Only persisted "
193 "if do_illumination_correction is True.",
194 name="background_to_photometric_ratio",
195 storageClass="Image",
196 dimensions=("instrument", "visit", "detector"),
197 )
198
199 # Optional outputs
200 mask = connectionTypes.Output(
201 doc="The mask plane of the calibrated exposure, written as a separate "
202 "output so that it can be passed to downstream tasks even if the "
203 "exposure is removed to save storage.",
204 name="preliminary_visit_mask",
205 storageClass="Mask",
206 dimensions=("instrument", "visit", "detector"),
207 )
208 psf_stars_footprints = connectionTypes.Output(
209 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination; "
210 "includes source footprints.",
211 name="initial_psf_stars_footprints_detector",
212 storageClass="SourceCatalog",
213 dimensions=["instrument", "visit", "detector"],
214 )
215 psf_stars = connectionTypes.Output(
216 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination.",
217 name="initial_psf_stars_detector",
218 storageClass="ArrowAstropy",
219 dimensions=["instrument", "visit", "detector"],
220 )
221 astrometry_matches = connectionTypes.Output(
222 doc="Source to reference catalog matches from the astrometry solver.",
223 name="initial_astrometry_match_detector",
224 storageClass="Catalog",
225 dimensions=("instrument", "visit", "detector"),
226 )
227 photometry_matches = connectionTypes.Output(
228 doc="Source to reference catalog matches from the photometry solver.",
229 name="initial_photometry_match_detector",
230 storageClass="Catalog",
231 dimensions=("instrument", "visit", "detector"),
232 )
233
234 def __init__(self, *, config=None):
235 super().__init__(config=config)
236 if "psf_stars" not in config.optional_outputs:
237 del self.psf_stars
238 if "psf_stars_footprints" not in config.optional_outputs:
239 del self.psf_stars_footprints
240 if "astrometry_matches" not in config.optional_outputs:
241 del self.astrometry_matches
242 if "photometry_matches" not in config.optional_outputs:
243 del self.photometry_matches
244 if "mask" not in config.optional_outputs:
245 del self.mask
246 if not config.do_calibrate_pixels:
247 del self.applied_photo_calib
248 if not config.do_illumination_correction:
249 del self.background_flat
252 if not config.useButlerCamera:
253 del self.camera_model
254
255
256class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=CalibrateImageConnections):
257 optional_outputs = pexConfig.ListField(
258 doc="Which optional outputs to save (as their connection name)?",
259 dtype=str,
260 # TODO: note somewhere to disable this for benchmarking, but should
261 # we always have it on for production runs?
262 default=["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches", "mask"],
263 optional=False
264 )
265
266 # To generate catalog ids consistently across subtasks.
267 id_generator = lsst.meas.base.DetectorVisitIdGeneratorConfig.make_field()
268
269 snap_combine = pexConfig.ConfigurableField(
271 doc="Task to combine two snaps to make one exposure.",
272 )
273
274 # subtasks used during psf characterization
275 install_simple_psf = pexConfig.ConfigurableField(
277 doc="Task to install a simple PSF model into the input exposure to use "
278 "when detecting bright sources for PSF estimation.",
279 )
280 psf_repair = pexConfig.ConfigurableField(
281 target=repair.RepairTask,
282 doc="Task to repair cosmic rays on the exposure before PSF determination.",
283 )
284 psf_subtract_background = pexConfig.ConfigurableField(
286 doc="Task to perform intial background subtraction, before first detection pass.",
287 )
288 psf_detection = pexConfig.ConfigurableField(
290 doc="Task to detect sources for PSF determination."
291 )
292 do_adaptive_threshold_detection = pexConfig.Field(
293 dtype=bool,
294 default=True,
295 doc="Implement the adaptive detection thresholding approach?",
296 )
297 psf_adaptive_threshold_detection = pexConfig.ConfigurableField(
298 target=AdaptiveThresholdDetectionTask,
299 doc="Task to adaptively detect sources for PSF determination.",
300 )
301 psf_source_measurement = pexConfig.ConfigurableField(
303 doc="Task to measure sources to be used for psf estimation."
304 )
305 psf_measure_psf = pexConfig.ConfigurableField(
307 doc="Task to measure the psf on bright sources."
308 )
309 psf_normalized_calibration_flux = pexConfig.ConfigurableField(
311 doc="Task to normalize the calibration flux (e.g. compensated tophats) "
312 "for the bright stars used for psf estimation.",
313 )
314
315 # TODO DM-39203: we can remove aperture correction from this task once we
316 # are using the shape-based star/galaxy code.
317 measure_aperture_correction = pexConfig.ConfigurableField(
319 doc="Task to compute the aperture correction from the bright stars."
320 )
321
322 # subtasks used during star measurement
323 star_background = pexConfig.ConfigurableField(
325 doc="Task to perform final background subtraction, just before photoCal.",
326 )
327 star_background_min_footprints = pexConfig.Field(
328 dtype=int,
329 default=3,
330 doc="Minimum number of footprints in the detection mask for star_background measurement. "
331 "This number will get adjusted to the fraction config.star_background_peak_fraction "
332 "of the detected peaks if that number is larger. If the number of footprints is less "
333 "than the minimum, the detection threshold is iteratively increased until the "
334 "threshold is met.",
335 )
336 star_background_peak_fraction = pexConfig.Field(
337 dtype=float,
338 default=0.01,
339 doc="The minimum number of footprints in the detection mask for star_background measuremen "
340 "gets set to the maximum of this fraction of the detected peaks and the value set in "
341 "config.star_background_min_footprints. If the number of footprints is less than the "
342 "current minimum set, the detection threshold is iteratively increased until the "
343 "threshold is met.",
344 )
345 star_detection = pexConfig.ConfigurableField(
347 doc="Task to detect stars to return in the output catalog."
348 )
349 star_sky_sources = pexConfig.ConfigurableField(
351 doc="Task to generate sky sources ('empty' regions where there are no detections).",
352 )
353 do_downsample_footprints = pexConfig.Field(
354 dtype=bool,
355 default=False,
356 doc="Downsample footprints prior to deblending to optimize speed?",
357 )
358 downsample_max_footprints = pexConfig.Field(
359 dtype=int,
360 default=1000,
361 doc="Maximum number of non-sky-source footprints to use if do_downsample_footprints is True,",
362 )
363 star_deblend = pexConfig.ConfigurableField(
365 doc="Split blended sources into their components."
366 )
367 star_measurement = pexConfig.ConfigurableField(
369 doc="Task to measure stars to return in the output catalog."
370 )
371 star_normalized_calibration_flux = pexConfig.ConfigurableField(
373 doc="Task to apply the normalization for calibration fluxes (e.g. compensated tophats) "
374 "for the final output star catalog.",
375 )
376 star_apply_aperture_correction = pexConfig.ConfigurableField(
378 doc="Task to apply aperture corrections to the selected stars."
379 )
380 star_catalog_calculation = pexConfig.ConfigurableField(
382 doc="Task to compute extendedness values on the star catalog, "
383 "for the star selector to remove extended sources."
384 )
385 star_set_primary_flags = pexConfig.ConfigurableField(
387 doc="Task to add isPrimary to the catalog."
388 )
389 star_selector = lsst.meas.algorithms.sourceSelectorRegistry.makeField(
390 default="science",
391 doc="Task to select reliable stars to use for calibration."
392 )
393
394 # final calibrations and statistics
395 astrometry = pexConfig.ConfigurableField(
397 doc="Task to perform astrometric calibration to fit a WCS.",
398 )
399 astrometry_ref_loader = pexConfig.ConfigField(
401 doc="Configuration of reference object loader for astrometric fit.",
402 )
403 photometry = pexConfig.ConfigurableField(
405 doc="Task to perform photometric calibration to fit a PhotoCalib.",
406 )
407 photometry_ref_loader = pexConfig.ConfigField(
409 doc="Configuration of reference object loader for photometric fit.",
410 )
411 doMaskDiffractionSpikes = pexConfig.Field(
412 dtype=bool,
413 default=False,
414 doc="If True, load the photometric reference catalog again but select "
415 "only bright stars. Use the bright star catalog to set the SPIKE "
416 "mask for regions likely contaminated by diffraction spikes.",
417 )
418 diffractionSpikeMask = pexConfig.ConfigurableField(
420 doc="Task to identify and mask the diffraction spikes of bright stars.",
421 )
422
423 compute_summary_stats = pexConfig.ConfigurableField(
425 doc="Task to to compute summary statistics on the calibrated exposure."
426 )
427
428 do_illumination_correction = pexConfig.Field(
429 dtype=bool,
430 default=False,
431 doc="If True, apply the illumination correction. This assumes that the "
432 "input image has already been flat-fielded such that it is suitable "
433 "for background subtraction.",
434 )
435
436 do_calibrate_pixels = pexConfig.Field(
437 dtype=bool,
438 default=True,
439 doc=(
440 "If True, apply the photometric calibration to the image pixels "
441 "and background model, and attach an identity PhotoCalib to "
442 "the output image to reflect this. If False`, leave the image "
443 "and background uncalibrated and attach the PhotoCalib that maps "
444 "them to physical units."
445 )
446 )
447
448 do_include_astrometric_errors = pexConfig.Field(
449 dtype=bool,
450 default=True,
451 doc="If True, include astrometric errors in the output catalog.",
452 )
453
454 background_stats_ignored_pixel_masks = pexConfig.ListField(
455 dtype=str,
456 default=["SAT", "SUSPECT", "SPIKE"],
457 doc="Pixel mask flags to ignore when calculating post-sky-subtraction "
458 "background statistics. These are added to those ignored by the "
459 "meas.algorithms.SubtractBackgroundConfig algorithm."
460 )
461
462 run_sattle = pexConfig.Field(
463 dtype=bool,
464 default=False,
465 doc="If True, the sattle service will populate a cache for later use "
466 "in ip_diffim.detectAndMeasure alert verification."
467 )
468
469 sattle_historical = pexConfig.Field(
470 dtype=bool,
471 default=False,
472 doc="If re-running a pipeline that requires sattle, this should be set "
473 "to True. This will populate sattle's cache with the historic data "
474 "closest in time to the exposure.",
475 )
476
477 useButlerCamera = pexConfig.Field(
478 dtype=bool,
479 default=False,
480 doc="If True, use a camera distortion model generated elsewhere in "
481 "the pipeline combined with the telescope boresight as a starting "
482 "point for fitting the WCS, instead of using the WCS attached to "
483 "the exposure, which is generated from the boresight and the "
484 "camera model from the obs_* package."
485 )
486
487 def setDefaults(self):
488 super().setDefaults()
489
490 # Use a very broad PSF here, to throughly reject CRs.
491 # TODO investigation: a large initial psf guess may make stars look
492 # like CRs for very good seeing images.
493 self.install_simple_psf.fwhm = 4
494
495 # S/N>=50 sources for PSF determination, but detection to S/N=10.
496 # The thresholdValue sets the minimum flux in a pixel to be included
497 # in the footprint, while peaks are only detected when they are above
498 # thresholdValue * includeThresholdMultiplier. The low thresholdValue
499 # ensures that the footprints are large enough for the noise replacer
500 # to mask out faint undetected neighbors that are not to be measured.
501 self.psf_detection.thresholdValue = 10.0
502 self.psf_detection.includeThresholdMultiplier = 5.0
503 # TODO investigation: Probably want False here, but that may require
504 # tweaking the background spatial scale, to make it small enough to
505 # prevent extra peaks in the wings of bright objects.
506 self.psf_detection.doTempLocalBackground = False
507 self.psf_detection.reEstimateBackground = False
508
509 # Minimal measurement plugins for PSF determination.
510 # TODO DM-39203: We can drop GaussianFlux and PsfFlux, if we use
511 # shapeHSM/moments for star/galaxy separation.
512 # TODO DM-39203: we can remove aperture correction from this task
513 # once we are using the shape-based star/galaxy code.
514 self.psf_source_measurement.plugins = ["base_PixelFlags",
515 "base_SdssCentroid",
516 "ext_shapeHSM_HsmSourceMoments",
517 "base_CircularApertureFlux",
518 "base_GaussianFlux",
519 "base_PsfFlux",
520 "base_CompensatedTophatFlux",
521 ]
522 self.psf_source_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
523 # Only measure apertures we need for PSF measurement.
524 self.psf_source_measurement.plugins["base_CircularApertureFlux"].radii = [12.0]
525 self.psf_source_measurement.plugins["base_CompensatedTophatFlux"].apertures = [12]
526 # TODO DM-40843: Remove this line once this is the psfex default.
527 self.psf_measure_psf.psfDeterminer["psfex"].photometricFluxField = \
528 "base_CircularApertureFlux_12_0_instFlux"
529
530 # No extendeness information available: we need the aperture
531 # corrections to determine that.
532 self.measure_aperture_correction.sourceSelector["science"].doUnresolved = False
533 self.measure_aperture_correction.sourceSelector["science"].flags.good = ["calib_psf_used"]
534 self.measure_aperture_correction.sourceSelector["science"].flags.bad = []
535
536 # Detection for good S/N for astrometry/photometry and other
537 # downstream tasks; detection mask to S/N>=5, but S/N>=10 peaks.
538 self.star_detection.reEstimateBackground = False
539 self.star_detection.thresholdValue = 5.0
540 self.star_detection.includeThresholdMultiplier = 2.0
541 self.star_measurement.plugins = ["base_PixelFlags",
542 "base_SdssCentroid",
543 "ext_shapeHSM_HsmSourceMoments",
544 "ext_shapeHSM_HsmPsfMoments",
545 "base_GaussianFlux",
546 "base_PsfFlux",
547 "base_CircularApertureFlux",
548 "base_ClassificationSizeExtendedness",
549 "base_CompensatedTophatFlux",
550 ]
551 self.star_measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
552 self.star_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
553 # Only measure the apertures we need for star selection.
554 self.star_measurement.plugins["base_CircularApertureFlux"].radii = [12.0]
555 self.star_measurement.plugins["base_CompensatedTophatFlux"].apertures = [12]
556
557 # We measure and apply the normalization aperture correction with
558 # the psf_normalized_calibration_flux task, and we only apply the
559 # normalization aperture correction for the full list of stars.
560 self.star_normalized_calibration_flux.do_measure_ap_corr = False
561
562 # Select stars with reliable measurements and no bad flags.
563 self.star_selector["science"].doFlags = True
564 self.star_selector["science"].doUnresolved = True
565 self.star_selector["science"].doSignalToNoise = True
566 self.star_selector["science"].signalToNoise.minimum = 10.0
567 # Keep sky sources in the output catalog, even though they aren't
568 # wanted for calibration.
569 self.star_selector["science"].doSkySources = True
570 # Set the flux and error fields
571 self.star_selector["science"].signalToNoise.fluxField = "slot_CalibFlux_instFlux"
572 self.star_selector["science"].signalToNoise.errField = "slot_CalibFlux_instFluxErr"
573
574 # Use the affine WCS fitter (assumes we have a good camera geometry).
575 self.astrometry.wcsFitter.retarget(lsst.meas.astrom.FitAffineWcsTask)
576 # phot_g_mean is the primary Gaia band for all input bands.
577 self.astrometry_ref_loader.anyFilterMapsToThis = "phot_g_mean"
578
579 # Only reject sky sources; we already selected good stars.
580 self.astrometry.sourceSelector["science"].doFlags = True
581 self.astrometry.sourceSelector["science"].flags.good = [] # ["calib_psf_candidate"]
582 # self.astrometry.sourceSelector["science"].flags.bad = []
583 self.astrometry.sourceSelector["science"].doUnresolved = False
584 self.astrometry.sourceSelector["science"].doIsolated = False
585 self.astrometry.sourceSelector["science"].doRequirePrimary = False
586 self.photometry.match.sourceSelection.doFlags = True
587 self.photometry.match.sourceSelection.flags.bad = ["sky_source"]
588 # Unset the (otherwise reasonable, but we've already made the
589 # selections we want above) selection settings in PhotoCalTask.
590 self.photometry.match.sourceSelection.doRequirePrimary = False
591 self.photometry.match.sourceSelection.doUnresolved = False
592
593 def validate(self):
594 super().validate()
595
596 # Ensure that the normalization calibration flux tasks
597 # are configured correctly.
598 if not self.psf_normalized_calibration_flux.do_measure_ap_corr:
599 msg = ("psf_normalized_calibration_flux task must be configured with do_measure_ap_corr=True "
600 "or else the normalization and calibration flux will not be properly measured.")
601 raise pexConfig.FieldValidationError(
602 CalibrateImageConfig.psf_normalized_calibration_flux, self, msg,
603 )
604 if self.star_normalized_calibration_flux.do_measure_ap_corr:
605 msg = ("star_normalized_calibration_flux task must be configured with do_measure_ap_corr=False "
606 "to apply the previously measured normalization to the full catalog of calibration "
607 "fluxes.")
608 raise pexConfig.FieldValidationError(
609 CalibrateImageConfig.star_normalized_calibration_flux, self, msg,
610 )
611
612 # Ensure base_LocalPhotoCalib and base_LocalWcs plugins are not run,
613 # because they'd be running too early to pick up the fitted PhotoCalib
614 # and WCS.
615 if "base_LocalWcs" in self.psf_source_measurement.plugins.names:
616 raise pexConfig.FieldValidationError(
617 CalibrateImageConfig.psf_source_measurement,
618 self,
619 "base_LocalWcs cannot run CalibrateImageTask, as it would be run before the astrometry fit."
620 )
621 if "base_LocalWcs" in self.star_measurement.plugins.names:
622 raise pexConfig.FieldValidationError(
623 CalibrateImageConfig.star_measurement,
624 self,
625 "base_LocalWcs cannot run CalibrateImageTask, as it would be run before the astrometry fit."
626 )
627 if "base_LocalPhotoCalib" in self.psf_source_measurement.plugins.names:
628 raise pexConfig.FieldValidationError(
629 CalibrateImageConfig.psf_source_measurement,
630 self,
631 "base_LocalPhotoCalib cannot run CalibrateImageTask, "
632 "as it would be run before the photometry fit."
633 )
634 if "base_LocalPhotoCalib" in self.star_measurement.plugins.names:
635 raise pexConfig.FieldValidationError(
636 CalibrateImageConfig.star_measurement,
637 self,
638 "base_LocalPhotoCalib cannot run CalibrateImageTask, "
639 "as it would be run before the photometry fit."
640 )
641
642 # Check for illumination correction and background consistency.
644 if not self.psf_subtract_background.doApplyFlatBackgroundRatio:
645 raise pexConfig.FieldValidationError(
646 CalibrateImageConfig.psf_subtract_background,
647 self,
648 "CalibrateImageTask.psf_subtract_background must be configured with "
649 "doApplyFlatBackgroundRatio=True if do_illumination_correction=True."
650 )
651 if self.psf_detection.reEstimateBackground:
652 if not self.psf_detection.doApplyFlatBackgroundRatio:
653 raise pexConfig.FieldValidationError(
654 CalibrateImageConfig.psf_detection,
655 self,
656 "CalibrateImageTask.psf_detection background must be configured with "
657 "doApplyFlatBackgroundRatio=True if do_illumination_correction=True."
658 )
659 if self.star_detection.reEstimateBackground:
660 if not self.star_detection.doApplyFlatBackgroundRatio:
661 raise pexConfig.FieldValidationError(
662 CalibrateImageConfig.star_detection,
663 self,
664 "CalibrateImageTask.star_detection background must be configured with "
665 "doApplyFlatBackgroundRatio=True if do_illumination_correction=True."
666 )
667 if not self.star_background.doApplyFlatBackgroundRatio:
668 raise pexConfig.FieldValidationError(
669 CalibrateImageConfig.star_background,
670 self,
671 "CalibrateImageTask.star_background background must be configured with "
672 "doApplyFlatBackgroundRatio=True if do_illumination_correction=True."
673 )
674
675 if self.run_sattle:
676 if not os.getenv("SATTLE_URI_BASE"):
677 raise pexConfig.FieldValidationError(CalibrateImageConfig.run_sattle, self,
678 "Sattle requested but URI environment variable not set.")
679
681 if not self.psf_detection.reEstimateBackground:
682 raise pexConfig.FieldValidationError(
683 CalibrateImageConfig.psf_detection,
684 self,
685 "If not using the adaptive threshold detection implementation (i.e. "
686 "config.do_adaptive_threshold_detection = False), CalibrateImageTask.psf_detection "
687 "background must be configured with "
688 "reEstimateBackground = True to maintain original behavior."
689 )
690 if not self.star_detection.reEstimateBackground:
691 raise pexConfig.FieldValidationError(
692 CalibrateImageConfig.psf_detection,
693 self,
694 "If not using the adaptive threshold detection implementation "
695 "(i.e. config.do_adaptive_threshold_detection = False), "
696 "CalibrateImageTask.star_detection background must be configured "
697 "with reEstimateBackground = True to maintain original behavior."
698 )
699
700
701class CalibrateImageTask(pipeBase.PipelineTask):
702 """Compute the PSF, aperture corrections, astrometric and photometric
703 calibrations, and summary statistics for a single science exposure, and
704 produce a catalog of brighter stars that were used to calibrate it.
705
706 Parameters
707 ----------
708 initial_stars_schema : `lsst.afw.table.Schema`
709 Schema of the initial_stars output catalog.
710 """
711 _DefaultName = "calibrateImage"
712 ConfigClass = CalibrateImageConfig
713
714 def __init__(self, initial_stars_schema=None, **kwargs):
715 super().__init__(**kwargs)
716 self.makeSubtask("snap_combine")
717
718 # PSF determination subtasks
719 self.makeSubtask("install_simple_psf")
720 self.makeSubtask("psf_repair")
721 self.makeSubtask("psf_subtract_background")
722 self.psf_schema = afwTable.SourceTable.makeMinimalSchema()
723 self.psf_schema.addField(
724 'psf_max_value',
725 type=np.float32,
726 doc="PSF max value.",
727 )
728 afwTable.CoordKey.addErrorFields(self.psf_schema)
729 self.makeSubtask("psf_detection", schema=self.psf_schema)
730 self.makeSubtask("psf_source_measurement", schema=self.psf_schema)
731 self.makeSubtask("psf_adaptive_threshold_detection")
732 self.makeSubtask("psf_measure_psf", schema=self.psf_schema)
733 self.makeSubtask("psf_normalized_calibration_flux", schema=self.psf_schema)
734
735 self.makeSubtask("measure_aperture_correction", schema=self.psf_schema)
736 self.makeSubtask("astrometry", schema=self.psf_schema)
737
738 # star measurement subtasks
739 if initial_stars_schema is None:
740 initial_stars_schema = afwTable.SourceTable.makeMinimalSchema()
741
742 # These fields let us track which sources were used for psf modeling,
743 # astrometric fitting, and aperture correction calculations.
744 self.psf_fields = ("calib_psf_candidate", "calib_psf_used", "calib_psf_reserved",
745 "calib_astrometry_used",
746 # TODO DM-39203:
747 # these can be removed once apcorr is gone.
748 "apcorr_slot_CalibFlux_used", "apcorr_base_GaussianFlux_used",
749 "apcorr_base_PsfFlux_used",)
750 for field in self.psf_fields:
751 item = self.psf_schema.find(field)
752 initial_stars_schema.addField(item.getField())
753 id_type = self.psf_schema["id"].asField().getTypeString()
754 psf_max_value_type = self.psf_schema['psf_max_value'].asField().getTypeString()
755 initial_stars_schema.addField("psf_id",
756 type=id_type,
757 doc="id of this source in psf_stars; 0 if there is no match.")
758 initial_stars_schema.addField("psf_max_value",
759 type=psf_max_value_type,
760 doc="Maximum value in the star image used to train PSF.")
761
762 afwTable.CoordKey.addErrorFields(initial_stars_schema)
763 self.makeSubtask("star_background")
764 self.makeSubtask("star_detection", schema=initial_stars_schema)
765 self.makeSubtask("star_sky_sources", schema=initial_stars_schema)
766 self.makeSubtask("star_deblend", schema=initial_stars_schema)
767 self.makeSubtask("star_measurement", schema=initial_stars_schema)
768 self.makeSubtask("star_normalized_calibration_flux", schema=initial_stars_schema)
769
770 self.makeSubtask("star_apply_aperture_correction", schema=initial_stars_schema)
771 self.makeSubtask("star_catalog_calculation", schema=initial_stars_schema)
772 self.makeSubtask("star_set_primary_flags", schema=initial_stars_schema, isSingleFrame=True)
773 self.makeSubtask("star_selector")
774 self.makeSubtask("photometry", schema=initial_stars_schema)
775 if self.config.doMaskDiffractionSpikes:
776 self.makeSubtask("diffractionSpikeMask")
777 self.makeSubtask("compute_summary_stats")
778
779 # The final catalog will have calibrated flux columns, which we add to
780 # the init-output schema by calibrating our zero-length catalog with an
781 # arbitrary dummy PhotoCalib. We also use this schema to initialze
782 # the stars catalog in order to ensure it's the same even when we hit
783 # an error (and write partial outputs) before calibrating the catalog
784 # - note that calibrateCatalog will happily reuse existing output
785 # columns.
786 dummy_photo_calib = afwImage.PhotoCalib(1.0, 0, bbox=lsst.geom.Box2I())
787 self.initial_stars_schema = dummy_photo_calib.calibrateCatalog(
788 afwTable.SourceCatalog(initial_stars_schema)
789 )
790
791 def runQuantum(self, butlerQC, inputRefs, outputRefs):
792 inputs = butlerQC.get(inputRefs)
793 exposures = inputs.pop("exposures")
794
795 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId)
796
798 dataIds=[ref.datasetRef.dataId for ref in inputRefs.astrometry_ref_cat],
799 refCats=inputs.pop("astrometry_ref_cat"),
800 name=self.config.connections.astrometry_ref_cat,
801 config=self.config.astrometry_ref_loader, log=self.log)
802 self.astrometry.setRefObjLoader(astrometry_loader)
803
805 dataIds=[ref.datasetRef.dataId for ref in inputRefs.photometry_ref_cat],
806 refCats=inputs.pop("photometry_ref_cat"),
807 name=self.config.connections.photometry_ref_cat,
808 config=self.config.photometry_ref_loader, log=self.log)
809 self.photometry.match.setRefObjLoader(photometry_loader)
810
811 if self.config.doMaskDiffractionSpikes:
812 # Use the same photometry reference catalog for the bright star
813 # mask.
814 self.diffractionSpikeMask.setRefObjLoader(photometry_loader)
815
816 if self.config.do_illumination_correction:
817 background_flat = inputs.pop("background_flat")
818 illumination_correction = inputs.pop("illumination_correction")
819 else:
820 background_flat = None
821 illumination_correction = None
822
823 camera_model = None
824 if self.config.useButlerCamera:
825 if "camera_model" in inputs:
826 camera_model = inputs.pop("camera_model")
827 else:
828 self.log.warning("useButlerCamera=True, but camera is not available for filter %s. The "
829 "astrometry fit will use the WCS already attached to the exposure.",
830 exposures[0].filter.bandLabel)
831
832 # This should not happen with a properly configured execution context.
833 assert not inputs, "runQuantum got more inputs than expected"
834
835 # Specify the fields that `annotate` needs below, to ensure they
836 # exist, even as None.
837 result = pipeBase.Struct(
838 exposure=None,
839 stars_footprints=None,
840 psf_stars_footprints=None,
841 background_to_photometric_ratio=None,
842 )
843 try:
844 self.run(
845 exposures=exposures,
846 result=result,
847 id_generator=id_generator,
848 background_flat=background_flat,
849 illumination_correction=illumination_correction,
850 camera_model=camera_model,
851 )
852 except pipeBase.AlgorithmError as e:
853 error = pipeBase.AnnotatedPartialOutputsError.annotate(
854 e,
855 self,
856 result.exposure,
857 result.psf_stars_footprints,
858 result.stars_footprints,
859 log=self.log
860 )
861 butlerQC.put(result, outputRefs)
862 raise error from e
863
864 butlerQC.put(result, outputRefs)
865
866 @timeMethod
867 def run(
868 self,
869 *,
870 exposures,
871 id_generator=None,
872 result=None,
873 background_flat=None,
874 illumination_correction=None,
875 camera_model=None,
876 ):
877 """Find stars and perform psf measurement, then do a deeper detection
878 and measurement and calibrate astrometry and photometry from that.
879
880 Parameters
881 ----------
882 exposures : `lsst.afw.image.Exposure` or \
883 `list` [`lsst.afw.image.Exposure`]
884 Post-ISR exposure(s), with an initial WCS, VisitInfo, and Filter.
885 Modified in-place during processing if only one is passed.
886 If two exposures are passed, treat them as snaps and combine
887 before doing further processing.
888 id_generator : `lsst.meas.base.IdGenerator`, optional
889 Object that generates source IDs and provides random seeds.
890 result : `lsst.pipe.base.Struct`, optional
891 Result struct that is modified to allow saving of partial outputs
892 for some failure conditions. If the task completes successfully,
893 this is also returned.
894 background_flat : `lsst.afw.image.Exposure`, optional
895 Background flat-field image.
896 illumination_correction : `lsst.afw.image.Exposure`, optional
897 Illumination correction image.
898 camera_model : `lsst.afw.cameraGeom.Camera`, optional
899 Camera to be used if constructing updated WCS.
900
901 Returns
902 -------
903 result : `lsst.pipe.base.Struct`
904 Results as a struct with attributes:
905
906 ``exposure``
907 Calibrated exposure, with pixels in nJy units.
908 (`lsst.afw.image.Exposure`)
909 ``stars``
910 Stars that were used to calibrate the exposure, with
911 calibrated fluxes and magnitudes.
912 (`astropy.table.Table`)
913 ``stars_footprints``
914 Footprints of stars that were used to calibrate the exposure.
915 (`lsst.afw.table.SourceCatalog`)
916 ``psf_stars``
917 Stars that were used to determine the image PSF.
918 (`astropy.table.Table`)
919 ``psf_stars_footprints``
920 Footprints of stars that were used to determine the image PSF.
921 (`lsst.afw.table.SourceCatalog`)
922 ``background``
923 Background that was fit to the exposure when detecting
924 ``stars``. (`lsst.afw.math.BackgroundList`)
925 ``applied_photo_calib``
926 Photometric calibration that was fit to the star catalog and
927 applied to the exposure. (`lsst.afw.image.PhotoCalib`)
928 This is `None` if ``config.do_calibrate_pixels`` is `False`.
929 ``astrometry_matches``
930 Reference catalog stars matches used in the astrometric fit.
931 (`list` [`lsst.afw.table.ReferenceMatch`] or
932 `lsst.afw.table.BaseCatalog`).
933 ``photometry_matches``
934 Reference catalog stars matches used in the photometric fit.
935 (`list` [`lsst.afw.table.ReferenceMatch`] or
936 `lsst.afw.table.BaseCatalog`).
937 ``mask``
938 Copy of the mask plane of `exposure`.
939 (`lsst.afw.image.Mask`)
940 """
941 if result is None:
942 result = pipeBase.Struct()
943 if id_generator is None:
944 id_generator = lsst.meas.base.IdGenerator()
945
946 result.exposure = self.snap_combine.run(exposures).exposure
947 self.log.info("Initial PhotoCalib: %s", result.exposure.getPhotoCalib())
948
949 result.exposure.metadata["LSST CALIB ILLUMCORR APPLIED"] = False
950
951 # Check input image processing.
952 if self.config.do_illumination_correction:
953 if not result.exposure.metadata.get("LSST ISR FLAT APPLIED", False):
954 raise pipeBase.InvalidQuantumError(
955 "Cannot use do_illumination_correction with an image that has not had a flat applied",
956 )
957
958 if camera_model:
959 if camera_model.get(result.exposure.detector.getId()):
960 self.log.info("Updating WCS with the provided camera model.")
961 self._update_wcs_with_camera_model(result.exposure, camera_model)
962 else:
963 self.log.warning(
964 "useButlerCamera=True, but detector %s is not available in the provided camera. The "
965 "astrometry fit will use the WCS already attached to the exposure.",
966 result.exposure.detector.getId())
967
968 result.background = None
969 summary_stat_catalog = None
970 # Some exposure components are set to initial placeholder objects
971 # while we try to bootstrap them. If we fail before we fit for them,
972 # we want to reset those components to None so the placeholders don't
973 # masquerade as the real thing.
974 have_fit_psf = False
975 have_fit_astrometry = False
976 have_fit_photometry = False
977 try:
978 result.background_to_photometric_ratio = self._apply_illumination_correction(
979 result.exposure,
980 background_flat,
981 illumination_correction,
982 )
983
984 result.psf_stars_footprints, result.background, _, adaptive_det_res_struct = self._compute_psf(
985 result.exposure,
986 id_generator,
987 background_to_photometric_ratio=result.background_to_photometric_ratio,
988 )
989 have_fit_psf = True
990
991 # Check if all centroids have been flagged. This should happen
992 # after the PSF is fit and recorded because even a junky PSF
993 # model is informative.
994 if result.psf_stars_footprints["slot_Centroid_flag"].all():
995 psf_shape = result.exposure.psf.computeShape(result.exposure.psf.getAveragePosition())
997 n_sources=len(result.psf_stars_footprints),
998 psf_shape_ixx=psf_shape.getIxx(),
999 psf_shape_iyy=psf_shape.getIyy(),
1000 psf_shape_ixy=psf_shape.getIxy(),
1001 psf_size=psf_shape.getDeterminantRadius(),
1002 )
1003
1004 if result.background is None:
1005 result.background = afwMath.BackgroundList()
1006
1007 self._measure_aperture_correction(result.exposure, result.psf_stars_footprints)
1008 result.psf_stars = result.psf_stars_footprints.asAstropy()
1009 # Run astrometry using PSF candidate stars.
1010 # Update "the psf_stars" source cooordinates with the current wcs.
1012 result.exposure.wcs,
1013 sourceList=result.psf_stars_footprints,
1014 include_covariance=self.config.do_include_astrometric_errors,
1015 )
1016 astrometry_matches, astrometry_meta = self._fit_astrometry(
1017 result.exposure, result.psf_stars_footprints
1018 )
1019 num_astrometry_matches = len(astrometry_matches)
1020 self.metadata["astrometry_matches_count"] = num_astrometry_matches
1021 if "astrometry_matches" in self.config.optional_outputs:
1022 result.astrometry_matches = lsst.meas.astrom.denormalizeMatches(astrometry_matches,
1023 astrometry_meta)
1024 result.psf_stars = result.psf_stars_footprints.asAstropy()
1025
1026 # Add diffraction spike mask here so it can be used (i.e. avoided)
1027 # in the adaptive background estimation.
1028 if self.config.doMaskDiffractionSpikes:
1029 self.diffractionSpikeMask.run(result.exposure)
1030
1031 if self.config.do_adaptive_threshold_detection:
1033 result,
1034 background_to_photometric_ratio=result.background_to_photometric_ratio,
1035 )
1036
1037 # Run the stars_detection subtask for the photometric calibration.
1038 result.stars_footprints = self._find_stars(
1039 result.exposure,
1040 result.background,
1041 id_generator,
1042 background_to_photometric_ratio=result.background_to_photometric_ratio,
1043 adaptive_det_res_struct=adaptive_det_res_struct,
1044 num_astrometry_matches=num_astrometry_matches,
1045 )
1046 psf = result.exposure.getPsf()
1047 psfSigma = psf.computeShape(result.exposure.getBBox().getCenter()).getDeterminantRadius()
1048 self._match_psf_stars(result.psf_stars_footprints, result.stars_footprints,
1049 psfSigma=psfSigma)
1050
1051 # Update the "stars" source cooordinates with the current wcs.
1053 result.exposure.wcs,
1054 sourceList=result.stars_footprints,
1055 include_covariance=self.config.do_include_astrometric_errors,
1056 )
1057
1058 summary_stat_catalog = result.stars_footprints
1059 result.stars = result.stars_footprints.asAstropy()
1060 self.metadata["star_count"] = np.sum(~result.stars["sky_source"])
1061
1062 # Validate the astrometric fit. Send in the stars_footprints
1063 # catalog so that its coords get set to NaN if the fit is deemed
1064 # a failure.
1065 self.astrometry.check(result.exposure, result.stars_footprints, len(astrometry_matches))
1066 result.stars = result.stars_footprints.asAstropy()
1067 have_fit_astrometry = True
1068
1069 result.stars_footprints, photometry_matches, \
1070 photometry_meta, photo_calib = self._fit_photometry(result.exposure, result.stars_footprints)
1071 have_fit_photometry = True
1072 self.metadata["photometry_matches_count"] = len(photometry_matches)
1073 # fit_photometry returns a new catalog, so we need a new astropy
1074 # table view.
1075 result.stars = result.stars_footprints.asAstropy()
1076 # summary stats don't make use of the calibrated fluxes, but we
1077 # might as well use the best catalog we've got in case that
1078 # changes, and help the old one get garbage-collected.
1079 summary_stat_catalog = result.stars_footprints
1080 if "photometry_matches" in self.config.optional_outputs:
1081 result.photometry_matches = lsst.meas.astrom.denormalizeMatches(photometry_matches,
1082 photometry_meta)
1083 if "mask" in self.config.optional_outputs:
1084 result.mask = result.exposure.mask.clone()
1085 except pipeBase.AlgorithmError:
1086 if not have_fit_psf:
1087 result.exposure.setPsf(None)
1088 if not have_fit_astrometry:
1089 result.exposure.setWcs(None)
1090 if not have_fit_photometry:
1091 result.exposure.setPhotoCalib(None)
1092 # Summary stat calculations can handle missing components
1093 # gracefully, but we want to run them as late as possible (but
1094 # still before we calibrate pixels, if we do that at all).
1095 # So we run them after we succeed or if we get an AlgorithmError.
1096 # We intentionally don't use 'finally' here because we don't
1097 # want to run them if we get some other kind of error.
1098 self._summarize(result.exposure, summary_stat_catalog, result.background)
1099 raise
1100 else:
1101 self._summarize(result.exposure, summary_stat_catalog, result.background)
1102
1103 # Output post-subtraction background stats to task metadata:
1104 # Specify the pixels flags to ignore, starting with those ignored
1105 # by the subtraction.
1106 bgIgnoredPixelMasks = lsst.meas.algorithms.SubtractBackgroundConfig().ignoredPixelMask.list()
1107 for maskName in self.config.background_stats_ignored_pixel_masks:
1108 if (maskName in result.exposure.mask.getMaskPlaneDict().keys()
1109 and maskName not in bgIgnoredPixelMasks):
1110 bgIgnoredPixelMasks += [maskName]
1111
1112 statsCtrl = afwMath.StatisticsControl()
1113 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(bgIgnoredPixelMasks))
1114
1115 statObj = afwMath.makeStatistics(result.exposure.getMaskedImage(), afwMath.MEDIAN, statsCtrl)
1116 median_bg, _ = statObj.getResult(afwMath.MEDIAN)
1117 self.metadata["bg_subtracted_skyPixel_instFlux_median"] = median_bg
1118
1119 statObj = afwMath.makeStatistics(result.exposure.getMaskedImage(), afwMath.STDEV, statsCtrl)
1120 stdev_bg, _ = statObj.getResult(afwMath.STDEV)
1121 self.metadata["bg_subtracted_skyPixel_instFlux_stdev"] = stdev_bg
1122
1123 self.metadata["bg_subtracted_skySource_flux_median"] = (
1124 np.median(result.stars[result.stars['sky_source']]['base_CircularApertureFlux_12_0_flux'])
1125 )
1126 self.metadata["bg_subtracted_skySource_flux_stdev"] = (
1127 np.std(result.stars[result.stars['sky_source']]['base_CircularApertureFlux_12_0_flux'])
1128 )
1129
1130 if self.config.do_calibrate_pixels:
1131 self._apply_photometry(
1132 result.exposure,
1133 result.background,
1134 background_to_photometric_ratio=result.background_to_photometric_ratio,
1135 )
1136 result.applied_photo_calib = photo_calib
1137 else:
1138 result.applied_photo_calib = None
1139
1140 self._recordMaskedPixelFractions(result.exposure)
1141
1142 if self.config.run_sattle:
1143 # send boresight and timing information to sattle so the cache
1144 # is populated by the time we reach ip_diffim detectAndMeasure.
1145 try:
1146 populate_sattle_visit_cache(result.exposure.getInfo().getVisitInfo(),
1147 historical=self.config.sattle_historical)
1148 self.log.info('Successfully triggered load of sattle visit cache')
1149 except requests.exceptions.HTTPError:
1150 self.log.exception("Sattle visit cache update failed; continuing with image processing")
1151
1152 return result
1153
1154 def _apply_illumination_correction(self, exposure, background_flat, illumination_correction):
1155 """Apply the illumination correction to a background-flattened image.
1156
1157 Parameters
1158 ----------
1159 exposure : `lsst.afw.image.Exposure`
1160 Exposure to convert to a photometric-flattened image.
1161 background_flat : `lsst.afw.image.Exposure`
1162 Flat image that had previously been applied to exposure.
1163 illumination_correction : `lsst.afw.image.Exposure`
1164 Illumination correction image to convert to photometric-flattened
1165 image.
1166
1167 Returns
1168 -------
1169 background_to_photometric_ratio : `lsst.afw.image.Image`
1170 Ratio image to convert a photometric-flattened image to/from
1171 a background-flattened image. Will be None if task not
1172 configured to use the illumination correction.
1173 """
1174 if not self.config.do_illumination_correction:
1175 return None
1176
1177 # From a raw image to a background-flattened image, we have:
1178 # bfi = image / background_flat
1179 # From a raw image to a photometric-flattened image, we have:
1180 # pfi = image / reference_flux_flat
1181 # pfi = image / (dome_flat * illumination_correction),
1182 # where the illumination correction contains the jacobian
1183 # of the wcs, converting to fluence units.
1184 # Currently background_flat == dome_flat, so we have for the
1185 # "background_to_photometric_ratio", the ratio of the background-
1186 # flattened image to the photometric-flattened image:
1187 # bfi / pfi = illumination_correction.
1188
1189 background_to_photometric_ratio = illumination_correction.image.clone()
1190
1191 # Dividing the ratio will convert a background-flattened image to
1192 # a photometric-flattened image.
1193 exposure.maskedImage /= background_to_photometric_ratio
1194
1195 exposure.metadata["LSST CALIB ILLUMCORR APPLIED"] = True
1196
1197 return background_to_photometric_ratio
1198
1199 def _compute_psf(self, exposure, id_generator, background_to_photometric_ratio=None):
1200 """Find bright sources detected on an exposure and fit a PSF model to
1201 them, repairing likely cosmic rays before detection.
1202
1203 Repair, detect, measure, and compute PSF twice, to ensure the PSF
1204 model does not include contributions from cosmic rays.
1205
1206 Parameters
1207 ----------
1208 exposure : `lsst.afw.image.Exposure`
1209 Exposure to detect and measure bright stars on.
1210 id_generator : `lsst.meas.base.IdGenerator`
1211 Object that generates source IDs and provides random seeds.
1212 background_to_photometric_ratio : `lsst.afw.image.Image`, optional
1213 Image to convert photometric-flattened image to
1214 background-flattened image.
1215
1216 Returns
1217 -------
1218 sources : `lsst.afw.table.SourceCatalog`
1219 Catalog of detected bright sources.
1220 background : `lsst.afw.math.BackgroundList`
1221 Background that was fit to the exposure during detection.
1222 cell_set : `lsst.afw.math.SpatialCellSet`
1223 PSF candidates returned by the psf determiner.
1224 """
1225 def log_psf(msg, addToMetadata=False):
1226 """Log the parameters of the psf and background, with a prepended
1227 message. There is also the option to add the PSF sigma to the task
1228 metadata.
1229
1230 Parameters
1231 ----------
1232 msg : `str`
1233 Message to prepend the log info with.
1234 addToMetadata : `bool`, optional
1235 Whether to add the final psf sigma value to the task
1236 metadata (the default is False).
1237 """
1238 position = exposure.psf.getAveragePosition()
1239 sigma = exposure.psf.computeShape(position).getDeterminantRadius()
1240 dimensions = exposure.psf.computeImage(position).getDimensions()
1241 if background is not None:
1242 median_background = np.median(background.getImage().array)
1243 else:
1244 median_background = 0.0
1245 self.log.info("%s sigma=%0.4f, dimensions=%s; median background=%0.2f",
1246 msg, sigma, dimensions, median_background)
1247 if addToMetadata:
1248 self.metadata["final_psf_sigma"] = sigma
1249
1250 self.log.info("First pass detection with Guassian PSF FWHM=%s pixels",
1251 self.config.install_simple_psf.fwhm)
1252 self.install_simple_psf.run(exposure=exposure)
1253
1254 background = self.psf_subtract_background.run(
1255 exposure=exposure,
1256 backgroundToPhotometricRatio=background_to_photometric_ratio,
1257 ).background
1258 log_psf("Initial PSF:")
1259 self.psf_repair.run(exposure=exposure, keepCRs=True)
1260
1261 table = afwTable.SourceTable.make(self.psf_schema, id_generator.make_table_id_factory())
1262 if not self.config.do_adaptive_threshold_detection:
1263 adaptive_det_res_struct = None
1264 # Re-estimate the background during this detection step, so that
1265 # measurement uses the most accurate background-subtraction.
1266 detections = self.psf_detection.run(
1267 table=table,
1268 exposure=exposure,
1269 background=background,
1270 backgroundToPhotometricRatio=background_to_photometric_ratio,
1271 )
1272 else:
1273 initialThreshold = self.config.psf_detection.thresholdValue
1274 initialThresholdMultiplier = self.config.psf_detection.includeThresholdMultiplier
1275 adaptive_det_res_struct = self.psf_adaptive_threshold_detection.run(
1276 table, exposure,
1277 initialThreshold=initialThreshold,
1278 initialThresholdMultiplier=initialThresholdMultiplier,
1279 )
1280 detections = adaptive_det_res_struct.detections
1281
1282 self.metadata["initial_psf_positive_footprint_count"] = detections.numPos
1283 self.metadata["initial_psf_negative_footprint_count"] = detections.numNeg
1284 self.metadata["initial_psf_positive_peak_count"] = detections.numPosPeaks
1285 self.metadata["initial_psf_negative_peak_count"] = detections.numNegPeaks
1286 self.psf_source_measurement.run(detections.sources, exposure)
1287 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources)
1288
1289 # This 2nd round of PSF fitting has been deemed unnecessary (and
1290 # sometimes even causing harm), so it is being skipped for the
1291 # adaptive threshold logic, but is left here for now to maintain
1292 # consistency with the previous logic for any users wanting to
1293 # revert to it.
1294 if not self.config.do_adaptive_threshold_detection:
1295 # Replace the initial PSF with something simpler for the second
1296 # repair/detect/measure/measure_psf step: this can help it
1297 # converge.
1298 self.install_simple_psf.run(exposure=exposure)
1299
1300 log_psf("Rerunning with simple PSF:")
1301 # TODO investigation: Should we only re-run repair here, to use the
1302 # new PSF? Maybe we *do* need to re-run measurement with PsfFlux,
1303 # to use the fitted PSF?
1304 # TODO investigation: do we need a separate measurement task here
1305 # for the post-psf_measure_psf step, since we only want to do
1306 # PsfFlux and GaussianFlux *after* we have a PSF? Maybe that's not
1307 # relevant once DM-39203 is merged?
1308 self.psf_repair.run(exposure=exposure, keepCRs=True)
1309 # Re-estimate the background during this detection step, so that
1310 # measurement uses the most accurate background-subtraction.
1311 detections = self.psf_detection.run(
1312 table=table,
1313 exposure=exposure,
1314 background=background,
1315 backgroundToPhotometricRatio=background_to_photometric_ratio,
1316 )
1317 self.psf_source_measurement.run(detections.sources, exposure)
1318 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources)
1319
1320 self.metadata["simple_psf_positive_footprint_count"] = detections.numPos
1321 self.metadata["simple_psf_negative_footprint_count"] = detections.numNeg
1322 self.metadata["simple_psf_positive_peak_count"] = detections.numPosPeaks
1323 self.metadata["simple_psf_negative_peak_count"] = detections.numNegPeaks
1324 log_psf("Final PSF:", addToMetadata=True)
1325
1326 # Final repair with final PSF, removing cosmic rays this time.
1327 self.psf_repair.run(exposure=exposure)
1328 # Final measurement with the CRs removed.
1329 self.psf_source_measurement.run(detections.sources, exposure)
1330
1331 # PSF is set on exposure; candidates are returned to use for
1332 # calibration flux normalization and aperture corrections.
1333 return detections.sources, background, psf_result.cellSet, adaptive_det_res_struct
1334
1335 def _measure_aperture_correction(self, exposure, bright_sources):
1336 """Measure and set the ApCorrMap on the Exposure, using
1337 previously-measured bright sources.
1338
1339 This function first normalizes the calibration flux and then
1340 the full set of aperture corrections are measured relative
1341 to this normalized calibration flux.
1342
1343 Parameters
1344 ----------
1345 exposure : `lsst.afw.image.Exposure`
1346 Exposure to set the ApCorrMap on.
1347 bright_sources : `lsst.afw.table.SourceCatalog`
1348 Catalog of detected bright sources; modified to include columns
1349 necessary for point source determination for the aperture
1350 correction calculation.
1351 """
1352 norm_ap_corr_map = self.psf_normalized_calibration_flux.run(
1353 exposure=exposure,
1354 catalog=bright_sources,
1355 ).ap_corr_map
1356
1357 ap_corr_map = self.measure_aperture_correction.run(exposure, bright_sources).apCorrMap
1358
1359 # Need to merge the aperture correction map from the normalization.
1360 for key in norm_ap_corr_map:
1361 ap_corr_map[key] = norm_ap_corr_map[key]
1362
1363 exposure.info.setApCorrMap(ap_corr_map)
1364
1365 def _find_stars(self, exposure, background, id_generator, background_to_photometric_ratio=None,
1366 adaptive_det_res_struct=None, num_astrometry_matches=None):
1367 """Detect stars on an exposure that has a PSF model, and measure their
1368 PSF, circular aperture, compensated gaussian fluxes.
1369
1370 Parameters
1371 ----------
1372 exposure : `lsst.afw.image.Exposure`
1373 Exposure to detect and measure stars on.
1374 background : `lsst.afw.math.BackgroundList`
1375 Background that was fit to the exposure during detection;
1376 modified in-place during subsequent detection.
1377 id_generator : `lsst.meas.base.IdGenerator`
1378 Object that generates source IDs and provides random seeds.
1379 background_to_photometric_ratio : `lsst.afw.image.Image`, optional
1380 Image to convert photometric-flattened image to
1381 background-flattened image.
1382
1383 Returns
1384 -------
1385 stars : `SourceCatalog`
1386 Sources that are very likely to be stars, with a limited set of
1387 measurements performed on them.
1388 """
1389 table = afwTable.SourceTable.make(self.initial_stars_schema.schema,
1390 id_generator.make_table_id_factory())
1391
1392 maxAdaptiveDetIter = 8
1393 threshFactor = 0.2
1394 if adaptive_det_res_struct is not None:
1395 for adaptiveDetIter in range(maxAdaptiveDetIter):
1396 adaptiveDetectionConfig = lsst.meas.algorithms.SourceDetectionConfig()
1397 adaptiveDetectionConfig.reEstimateBackground = False
1398 adaptiveDetectionConfig.includeThresholdMultiplier = 2.0
1399 psfThreshold = (
1400 adaptive_det_res_struct.thresholdValue*adaptive_det_res_struct.includeThresholdMultiplier
1401 )
1402 adaptiveDetectionConfig.thresholdValue = max(
1403 self.config.star_detection.thresholdValue,
1404 threshFactor*psfThreshold/adaptiveDetectionConfig.includeThresholdMultiplier
1405 )
1406 self.log.info("Using adaptive threshold detection (nIter = %d) with "
1407 "thresholdValue = %.2f and multiplier = %.1f",
1408 adaptiveDetIter, adaptiveDetectionConfig.thresholdValue,
1409 adaptiveDetectionConfig.includeThresholdMultiplier)
1410 adaptiveDetectionTask = lsst.meas.algorithms.SourceDetectionTask(
1411 config=adaptiveDetectionConfig
1412 )
1413 detections = adaptiveDetectionTask.run(
1414 table=table,
1415 exposure=exposure,
1416 background=background,
1417 backgroundToPhotometricRatio=background_to_photometric_ratio,
1418 )
1419 nFootprint = len(detections.sources)
1420 nPeak = 0
1421 nIsolated = 0
1422 for src in detections.sources:
1423 nPeakSrc = len(src.getFootprint().getPeaks())
1424 if nPeakSrc == 1:
1425 nIsolated += 1
1426 nPeak += nPeakSrc
1427 minIsolated = min(400, max(3, 0.005*nPeak, 0.6*num_astrometry_matches))
1428 if nFootprint > 0:
1429 self.log.info("Adaptive threshold detection _find_stars nIter: %d; "
1430 "nPeak/nFootprint = %.2f (max is 800); nIsolated = %d (min is %.1f).",
1431 adaptiveDetIter, nPeak/nFootprint, nIsolated, minIsolated)
1432 if nPeak/nFootprint > 800 or nIsolated < minIsolated:
1433 threshFactor = max(0.01, 1.5*threshFactor)
1434 self.log.warning("nPeak/nFootprint = %.2f (max is 800); nIsolated = %d "
1435 "(min is %.1f).", nPeak/nFootprint, nIsolated, minIsolated)
1436 else:
1437 break
1438 else:
1439 threshFactor *= 0.75
1440 self.log.warning("No footprints detected on image. Decreasing threshold "
1441 "factor to %.2f. and rerunning.", threshFactor)
1442 else:
1443 # Re-estimate the background during this detection step, so that
1444 # measurement uses the most accurate background-subtraction.
1445 detections = self.star_detection.run(
1446 table=table,
1447 exposure=exposure,
1448 background=background,
1449 backgroundToPhotometricRatio=background_to_photometric_ratio,
1450 )
1451 sources = detections.sources
1452 self.star_sky_sources.run(exposure.mask, id_generator.catalog_id, sources)
1453
1454 n_sky_sources = np.sum(sources["sky_source"])
1455 if (self.config.do_downsample_footprints
1456 and (len(sources) - n_sky_sources) > self.config.downsample_max_footprints):
1457 if exposure.info.id is None:
1458 self.log.warning("Exposure does not have a proper id; using 0 seed for downsample.")
1459 seed = 0
1460 else:
1461 seed = exposure.info.id & 0xFFFFFFFF
1462
1463 gen = np.random.RandomState(seed)
1464
1465 # Isolate the sky sources before downsampling.
1466 indices = np.arange(len(sources))[~sources["sky_source"]]
1467 indices = gen.choice(
1468 indices,
1469 size=self.config.downsample_max_footprints,
1470 replace=False,
1471 )
1472 skyIndices, = np.where(sources["sky_source"])
1473 indices = np.concatenate((indices, skyIndices))
1474
1475 self.log.info("Downsampling from %d to %d non-sky-source footprints.", len(sources), len(indices))
1476
1477 sel = np.zeros(len(sources), dtype=bool)
1478 sel[indices] = True
1479 sources = sources[sel]
1480
1481 # TODO investigation: Could this deblender throw away blends of
1482 # non-PSF sources?
1483 self.star_deblend.run(exposure=exposure, sources=sources)
1484 # The deblender may not produce a contiguous catalog; ensure
1485 # contiguity for subsequent tasks.
1486 if not sources.isContiguous():
1487 sources = sources.copy(deep=True)
1488
1489 # Measure everything, and use those results to select only stars.
1490 self.star_measurement.run(sources, exposure)
1491 self.metadata["post_deblend_source_count"] = np.sum(~sources["sky_source"])
1492 self.metadata["saturated_source_count"] = np.sum(sources["base_PixelFlags_flag_saturated"])
1493 self.metadata["bad_source_count"] = np.sum(sources["base_PixelFlags_flag_bad"])
1494
1495 # Run the normalization calibration flux task to apply the
1496 # normalization correction to create normalized
1497 # calibration fluxes.
1498 self.star_normalized_calibration_flux.run(exposure=exposure, catalog=sources)
1499 self.star_apply_aperture_correction.run(sources, exposure.apCorrMap)
1500 self.star_catalog_calculation.run(sources)
1501 self.star_set_primary_flags.run(sources)
1502
1503 result = self.star_selector.run(sources)
1504 # The star selector may not produce a contiguous catalog.
1505 if not result.sourceCat.isContiguous():
1506 return result.sourceCat.copy(deep=True)
1507 else:
1508 return result.sourceCat
1509
1510 def _match_psf_stars(self, psf_stars, stars, psfSigma=None):
1511 """Match calibration stars to psf stars, to identify which were psf
1512 candidates, and which were used or reserved during psf measurement
1513 and the astrometric fit.
1514
1515 Parameters
1516 ----------
1517 psf_stars : `lsst.afw.table.SourceCatalog`
1518 PSF candidate stars that were sent to the psf determiner and
1519 used in the astrometric fit. Used to populate psf and astrometry
1520 related flag fields.
1521 stars : `lsst.afw.table.SourceCatalog`
1522 Stars that will be used for calibration; psf-related fields will
1523 be updated in-place.
1524
1525 Notes
1526 -----
1527 This code was adapted from CalibrateTask.copyIcSourceFields().
1528 """
1529 control = afwTable.MatchControl()
1530 matchRadius = 3.0 if psfSigma is None else max(3.0, psfSigma) # in pixels
1531 # Return all matched objects, to separate blends.
1532 control.findOnlyClosest = False
1533 matches = afwTable.matchXy(psf_stars, stars, matchRadius, control)
1534 deblend_key = stars.schema["deblend_nChild"].asKey()
1535 matches = [m for m in matches if m[1].get(deblend_key) == 0]
1536
1537 # Because we had to allow multiple matches to handle parents, we now
1538 # need to prune to the best (closest) matches.
1539 # Closest matches is a dict of psf_stars source ID to Match record
1540 # (psf_stars source, sourceCat source, distance in pixels).
1541 best = {}
1542 for match_psf, match_stars, d in matches:
1543 match = best.get(match_psf.getId())
1544 if match is None or d <= match[2]:
1545 best[match_psf.getId()] = (match_psf, match_stars, d)
1546 matches = list(best.values())
1547 # We'll use this to construct index arrays into each catalog.
1548 ids = np.array([(match_psf.getId(), match_stars.getId()) for match_psf, match_stars, d in matches]).T
1549
1550 if (n_matches := len(matches)) == 0:
1551 raise NoPsfStarsToStarsMatchError(n_psf_stars=len(psf_stars), n_stars=len(stars))
1552
1553 self.log.info("%d psf/astrometry stars out of %d matched %d calib stars",
1554 n_matches, len(psf_stars), len(stars))
1555 self.metadata["matched_psf_star_count"] = n_matches
1556
1557 # Check that no stars sources are listed twice; we already know
1558 # that each match has a unique psf_stars id, due to using as the key
1559 # in best above.
1560 n_unique = len(set(m[1].getId() for m in matches))
1561 if n_unique != n_matches:
1562 self.log.warning("%d psf_stars matched only %d stars", n_matches, n_unique)
1563
1564 # The indices of the IDs, so we can update the flag fields as arrays.
1565 idx_psf_stars = np.searchsorted(psf_stars["id"], ids[0])
1566 idx_stars = np.searchsorted(stars["id"], ids[1])
1567 for field in self.psf_fields:
1568 result = np.zeros(len(stars), dtype=bool)
1569 result[idx_stars] = psf_stars[field][idx_psf_stars]
1570 stars[field] = result
1571 stars['psf_id'][idx_stars] = psf_stars['id'][idx_psf_stars]
1572 stars['psf_max_value'][idx_stars] = psf_stars['psf_max_value'][idx_psf_stars]
1573
1574 def _fit_astrometry(self, exposure, stars):
1575 """Fit an astrometric model to the data and return the reference
1576 matches used in the fit, and the fitted WCS.
1577
1578 Parameters
1579 ----------
1580 exposure : `lsst.afw.image.Exposure`
1581 Exposure that is being fit, to get PSF and other metadata from.
1582 Modified to add the fitted skyWcs.
1583 stars : `SourceCatalog`
1584 Good stars selected for use in calibration, with RA/Dec coordinates
1585 computed from the pixel positions and fitted WCS.
1586
1587 Returns
1588 -------
1589 matches : `list` [`lsst.afw.table.ReferenceMatch`]
1590 Reference/stars matches used in the fit.
1591 """
1592 result = self.astrometry.run(stars, exposure)
1593 return result.matches, result.matchMeta
1594
1595 def _fit_photometry(self, exposure, stars):
1596 """Fit a photometric model to the data and return the reference
1597 matches used in the fit, and the fitted PhotoCalib.
1598
1599 Parameters
1600 ----------
1601 exposure : `lsst.afw.image.Exposure`
1602 Exposure that is being fit, to get PSF and other metadata from.
1603 Has the fit `lsst.afw.image.PhotoCalib` attached, with pixel values
1604 unchanged.
1605 stars : `lsst.afw.table.SourceCatalog`
1606 Good stars selected for use in calibration.
1607 background : `lsst.afw.math.BackgroundList`
1608 Background that was fit to the exposure during detection of the
1609 above stars.
1610
1611 Returns
1612 -------
1613 calibrated_stars : `lsst.afw.table.SourceCatalog`
1614 Star catalog with flux/magnitude columns computed from the fitted
1615 photoCalib (instFlux columns are retained as well).
1616 matches : `list` [`lsst.afw.table.ReferenceMatch`]
1617 Reference/stars matches used in the fit.
1618 matchMeta : `lsst.daf.base.PropertyList`
1619 Metadata needed to unpersist matches, as returned by the matcher.
1620 photo_calib : `lsst.afw.image.PhotoCalib`
1621 Photometric calibration that was fit to the star catalog.
1622 """
1623 result = self.photometry.run(exposure, stars)
1624 calibrated_stars = result.photoCalib.calibrateCatalog(stars)
1625 exposure.setPhotoCalib(result.photoCalib)
1626 return calibrated_stars, result.matches, result.matchMeta, result.photoCalib
1627
1628 def _apply_photometry(self, exposure, background, background_to_photometric_ratio=None):
1629 """Apply the photometric model attached to the exposure to the
1630 exposure's pixels and an associated background model.
1631
1632 Parameters
1633 ----------
1634 exposure : `lsst.afw.image.Exposure`
1635 Exposure with the target `lsst.afw.image.PhotoCalib` attached.
1636 On return, pixel values will be calibrated and an identity
1637 photometric transform will be attached.
1638 background : `lsst.afw.math.BackgroundList`
1639 Background model to convert to nanojansky units in place.
1640 background_to_photometric_ratio : `lsst.afw.image.Image`, optional
1641 Image to convert photometric-flattened image to
1642 background-flattened image.
1643 """
1644 photo_calib = exposure.getPhotoCalib()
1645 exposure.maskedImage = photo_calib.calibrateImage(exposure.maskedImage)
1646 identity = afwImage.PhotoCalib(1.0,
1647 photo_calib.getCalibrationErr(),
1648 bbox=exposure.getBBox())
1649 exposure.setPhotoCalib(identity)
1650 exposure.metadata["BUNIT"] = "nJy"
1651
1652 assert photo_calib._isConstant, \
1653 "Background calibration assumes a constant PhotoCalib; PhotoCalTask should always return that."
1654
1655 for bg in background:
1656 # The statsImage is a view, but we can't assign to a function call
1657 # in python.
1658 binned_image = bg[0].getStatsImage()
1659 binned_image *= photo_calib.getCalibrationMean()
1660
1661 def _summarize(self, exposure, stars, background):
1662 """Compute summary statistics on the exposure and update in-place the
1663 calibrations attached to it.
1664
1665 Parameters
1666 ----------
1667 exposure : `lsst.afw.image.Exposure`
1668 Exposure that was calibrated, to get PSF and other metadata from.
1669 Should be in instrumental units with the photometric calibration
1670 attached.
1671 Modified to contain the computed summary statistics.
1672 stars : `SourceCatalog`
1673 Good stars selected used in calibration.
1674 background : `lsst.afw.math.BackgroundList`
1675 Background that was fit to the exposure during detection of the
1676 above stars. Should be in instrumental units.
1677 """
1678 summary = self.compute_summary_stats.run(exposure, stars, background)
1679 exposure.info.setSummaryStats(summary)
1680
1681 def _recordMaskedPixelFractions(self, exposure):
1682 """Record the fraction of all the pixels in an exposure
1683 that are masked with a given flag. Each fraction is
1684 recorded in the task metadata. One record per flag type.
1685
1686 Parameters
1687 ----------
1688 exposure : `lsst.afw.image.ExposureF`
1689 The target exposure to calculate masked pixel fractions for.
1690 """
1691
1692 mask = exposure.mask
1693 maskPlanes = list(mask.getMaskPlaneDict().keys())
1694 for maskPlane in maskPlanes:
1695 self.metadata[f"{maskPlane.lower()}_mask_fraction"] = (
1696 evaluateMaskFraction(mask, maskPlane)
1697 )
1698
1699 def _update_wcs_with_camera_model(self, exposure, cameraModel):
1700 """Replace the existing WCS with one generated using the pointing
1701 and the input camera model.
1702
1703 Parameters
1704 ----------
1705 exposure : `lsst.afw.image.ExposureF`
1706 The exposure whose WCS will be updated.
1707 cameraModel : `lsst.afw.cameraGeom.Camera`
1708 Camera to be used when constructing updated WCS.
1709 """
1710 detector = cameraModel[exposure.detector.getId()]
1711 newWcs = createInitialSkyWcs(exposure.visitInfo, detector)
1712 exposure.setWcs(newWcs)
1713
1714 def _compute_mask_fraction(self, mask, mask_planes, bad_mask_planes):
1715 """Evaluate the fraction of masked pixels in a (set of) mask plane(s).
1716
1717 Parameters
1718 ----------
1719 mask : `lsst.afw.image.Mask`
1720 The mask on which to evaluate the fraction.
1721 mask_planes : `list`, `str`
1722 The mask planes for which to evaluate the fraction.
1723 bad_mask_planes : `list`, `str`
1724 The mask planes to exclude from the computation.
1725
1726 Returns
1727 -------
1728 detected_fraction : `float`
1729 The calculated fraction of masked pixels
1730 """
1731 bad_pixel_mask = afwImage.Mask.getPlaneBitMask(bad_mask_planes)
1732 n_good_pix = np.sum(mask.array & bad_pixel_mask == 0)
1733 if n_good_pix == 0:
1734 detected_fraction = float("nan")
1735 return detected_fraction
1736 detected_pixel_mask = afwImage.Mask.getPlaneBitMask(mask_planes)
1737 n_detected_pix = np.sum((mask.array & detected_pixel_mask != 0)
1738 & (mask.array & bad_pixel_mask == 0))
1739 detected_fraction = n_detected_pix/n_good_pix
1740 return detected_fraction
1741
1742 def _compute_per_amp_fraction(self, exposure, detected_fraction, mask_planes, bad_mask_planes):
1743 """Evaluate the maximum per-amplifier fraction of masked pixels.
1744
1745 Parameters
1746 ----------
1747 exposure : `lsst.afw.image.ExposureF`
1748 The exposure on which to compute the per-amp masked fraction.
1749 detected_fraction : `float`
1750 The current detected_fraction of the ``mask_planes`` for the
1751 full detector.
1752 mask_planes : `list`, `str`
1753 The mask planes for which to evaluate the fraction.
1754 bad_mask_planes : `list`, `str`
1755 The mask planes to exclude from the computation.
1756
1757 Returns
1758 -------
1759 n_above_max_per_amp : `int`
1760 The number of amplifiers with masked fractions above a maximum
1761 value (set by the current full-detector ``detected_fraction``).
1762 highest_detected_fraction_per_amp : `float`
1763 The highest value of the per-amplifier fraction of masked pixels.
1764 no_zero_det_amps : `bool`
1765 A boolean representing whether any of the amplifiers has zero
1766 masked pixels.
1767 """
1768 highest_detected_fraction_per_amp = -9.99
1769 n_above_max_per_amp = 0
1770 n_no_zero_det_amps = 0
1771 no_zero_det_amps = True
1772 amps = exposure.detector.getAmplifiers()
1773 if amps is not None:
1774 for ia, amp in enumerate(amps):
1775 amp_bbox = amp.getBBox()
1776 exp_bbox = exposure.getBBox()
1777 if not exp_bbox.contains(amp_bbox):
1778 self.log.info("Bounding box of amplifier (%s) does not fit in exposure's "
1779 "bounding box (%s). Skipping...", amp_bbox, exp_bbox)
1780 continue
1781 sub_image = exposure.subset(amp.getBBox())
1782 detected_fraction_amp = self._compute_mask_fraction(sub_image.mask,
1783 mask_planes,
1784 bad_mask_planes)
1785 self.log.debug("Current detected fraction for amplifier %s = %.3f",
1786 amp.getName(), detected_fraction_amp)
1787 if detected_fraction_amp < 0.002:
1788 n_no_zero_det_amps += 1
1789 if n_no_zero_det_amps > 2:
1790 no_zero_det_amps = False
1791 break
1792 highest_detected_fraction_per_amp = max(detected_fraction_amp,
1793 highest_detected_fraction_per_amp)
1794 if highest_detected_fraction_per_amp > min(0.998, max(0.8, 3.0*detected_fraction)):
1795 n_above_max_per_amp += 1
1796 if n_above_max_per_amp > 2:
1797 break
1798 else:
1799 self.log.info("No amplifier object for detector %d, so skipping per-amp "
1800 "detection fraction checks.", exposure.detector.getId())
1801 return n_above_max_per_amp, highest_detected_fraction_per_amp, no_zero_det_amps
1802
1803 def _remeasure_star_background(self, result, background_to_photometric_ratio=None):
1804 """Remeasure the exposure's background with iterative adaptive
1805 threshold detection.
1806
1807 Parameters
1808 ----------
1809 result : `lsst.pipe.base.Struct`
1810 The current state of the result Strut from the run method of
1811 CalibrateImageTask. Will be modified in place.
1812 background_to_photometric_ratio : `lsst.afw.image.Image`, optional
1813 Image to convert photometric-flattened image to
1814 background-flattened image.
1815
1816 Returns
1817 -------
1818 result : `lsst.pipe.base.Struct`
1819 The modified result Struct with the new background subtracted.
1820 """
1821 # Restore the previously measured backgroud and remeasure it
1822 # using an adaptive threshold detection iteration to ensure a
1823 # "Goldilocks Zone" for the fraction of detected pixels.
1824 backgroundOrig = result.background.clone()
1825 median_background_orig = np.median(backgroundOrig.getImage().array)
1826 self.log.info("Original median_background = %.2f", median_background_orig)
1827 result.exposure.image.array += result.background.getImage().array
1828 result.background = afwMath.BackgroundList()
1829
1830 origMask = result.exposure.mask.clone()
1831 bad_mask_planes = ["BAD", "EDGE", "NO_DATA"]
1832 detected_mask_planes = ["DETECTED", "DETECTED_NEGATIVE"]
1833 detected_fraction_orig = self._compute_mask_fraction(result.exposure.mask,
1834 detected_mask_planes,
1835 bad_mask_planes)
1836 minDetFracForFinalBg = 0.02
1837 maxDetFracForFinalBg = 0.93
1838 # Dilate the current detected mask planes and don't clear
1839 # them in the detection step.
1840 nPixToDilate = 10
1841 maxAdaptiveDetIter = 10
1842 for dilateIter in range(maxAdaptiveDetIter):
1843 dilatedMask = origMask.clone()
1844 for maskName in detected_mask_planes:
1845 # Compute the grown detection mask plane using SpanSet
1846 detectedMaskBit = dilatedMask.getPlaneBitMask(maskName)
1847 detectedMaskSpanSet = SpanSet.fromMask(dilatedMask, detectedMaskBit)
1848 detectedMaskSpanSet = detectedMaskSpanSet.dilated(nPixToDilate)
1849 detectedMaskSpanSet = detectedMaskSpanSet.clippedTo(dilatedMask.getBBox())
1850 # Clear the detected mask plane
1851 detectedMask = dilatedMask.getMaskPlane(maskName)
1852 dilatedMask.clearMaskPlane(detectedMask)
1853 # Set the mask plane to the dilated one
1854 detectedMaskSpanSet.setMask(dilatedMask, detectedMaskBit)
1855
1856 detected_fraction_dilated = self._compute_mask_fraction(dilatedMask,
1857 detected_mask_planes,
1858 bad_mask_planes)
1859 if detected_fraction_dilated < maxDetFracForFinalBg or nPixToDilate == 1:
1860 break
1861 else:
1862 nPixToDilate -= 1
1863
1864 result.exposure.mask = dilatedMask
1865 self.log.warning("detected_fraction_orig = %.3f detected_fraction_dilated = %.3f",
1866 detected_fraction_orig, detected_fraction_dilated)
1867 n_above_max_per_amp = -99
1868 highest_detected_fraction_per_amp = float("nan")
1869
1870 # Check the per-amplifier detection fractions.
1871 n_above_max_per_amp, highest_detected_fraction_per_amp, no_zero_det_amps = \
1872 self._compute_per_amp_fraction(result.exposure, detected_fraction_dilated,
1873 detected_mask_planes, bad_mask_planes)
1874 self.log.warning("Dilated mask: n_above_max_per_amp = %d, "
1875 "highest_detected_fraction_per_amp = %.3f",
1876 n_above_max_per_amp, highest_detected_fraction_per_amp)
1877
1878 bgIgnoreMasksToAdd = ["SAT", "SUSPECT", "SPIKE"]
1879 detected_fraction = 1.0
1880 nFootprintTemp = 1e12
1881 starBackgroundDetectionConfig = lsst.meas.algorithms.SourceDetectionConfig()
1882 for maskName in bgIgnoreMasksToAdd:
1883 if (maskName in result.exposure.mask.getMaskPlaneDict().keys()
1884 and maskName not in starBackgroundDetectionConfig.background.ignoredPixelMask):
1885 starBackgroundDetectionConfig.background.ignoredPixelMask += [maskName]
1886 starBackgroundDetectionConfig.doTempLocalBackground = False
1887 starBackgroundDetectionConfig.nSigmaToGrow = 70.0
1888 starBackgroundDetectionConfig.reEstimateBackground = False
1889 starBackgroundDetectionConfig.includeThresholdMultiplier = 1.0
1890 starBackgroundDetectionConfig.thresholdValue = max(2.0, 0.2*median_background_orig)
1891 starBackgroundDetectionConfig.thresholdType = "pixel_stdev"
1892
1893 n_above_max_per_amp = -99
1894 highest_detected_fraction_per_amp = float("nan")
1895 doCheckPerAmpDetFraction = True
1896
1897 minFootprints = self.config.star_background_min_footprints
1898 maxIter = 40
1899 for nIter in range(maxIter):
1900 currentThresh = starBackgroundDetectionConfig.thresholdValue
1901 nZeroEncountered = 0
1902 if nFootprintTemp == 0:
1903 zeroFactor = min(0.98, 0.9 + 0.01*nZeroEncountered)
1904 starBackgroundDetectionConfig.thresholdValue = zeroFactor*currentThresh
1905 self.log.warning("No footprints detected. Decreasing threshold to %.2f and rerunning.",
1906 starBackgroundDetectionConfig.thresholdValue)
1907 starBackgroundDetectionTask = lsst.meas.algorithms.SourceDetectionTask(
1908 config=starBackgroundDetectionConfig)
1909 table = afwTable.SourceTable.make(self.initial_stars_schema.schema)
1910 tempDetections = starBackgroundDetectionTask.run(
1911 table=table, exposure=result.exposure, clearMask=True)
1912 nFootprintTemp = len(tempDetections.sources)
1913 minFootprints = max(self.config.star_background_min_footprints,
1914 int(self.config.star_background_peak_fraction*tempDetections.numPosPeaks))
1915 minFootprints = min(200, minFootprints)
1916 nZeroEncountered += 1
1917 if nFootprintTemp >= minFootprints:
1918 detected_fraction = self._compute_mask_fraction(result.exposure.mask,
1919 detected_mask_planes,
1920 bad_mask_planes)
1921 self.log.info("nIter = %d, thresh = %.2f: Fraction of pixels marked as DETECTED or "
1922 "DETECTED_NEGATIVE in star_background_detection = %.3f "
1923 "(max is %.3f; min is %.3f) nFootprint = %d (current min is %d)",
1924 nIter, starBackgroundDetectionConfig.thresholdValue,
1925 detected_fraction, maxDetFracForFinalBg, minDetFracForFinalBg,
1926 nFootprintTemp, minFootprints)
1927 break
1928 else:
1929 # Still not enough footprints, so make sure this loop is
1930 # entered again.
1931 if nFootprintTemp > 0 and nFootprintTemp < minFootprints:
1932 nFootprintTemp = 0
1933 continue
1934 if detected_fraction > maxDetFracForFinalBg or nFootprintTemp <= minFootprints:
1935 starBackgroundDetectionConfig.thresholdValue = 1.07*currentThresh
1936 if nFootprintTemp < minFootprints and detected_fraction > 0.9*maxDetFracForFinalBg:
1937 if nFootprintTemp == 1:
1938 starBackgroundDetectionConfig.thresholdValue = 1.4*currentThresh
1939 else:
1940 starBackgroundDetectionConfig.thresholdValue = 1.2*currentThresh
1941
1942 if n_above_max_per_amp > 1:
1943 starBackgroundDetectionConfig.thresholdValue = 1.1*currentThresh
1944 if detected_fraction < minDetFracForFinalBg:
1945 starBackgroundDetectionConfig.thresholdValue = 0.8*currentThresh
1946 starBackgroundDetectionTask = lsst.meas.algorithms.SourceDetectionTask(
1947 config=starBackgroundDetectionConfig)
1948 table = afwTable.SourceTable.make(self.initial_stars_schema.schema)
1949 tempDetections = starBackgroundDetectionTask.run(
1950 table=table, exposure=result.exposure, clearMask=True)
1951 result.exposure.mask |= dilatedMask
1952 nFootprintTemp = len(tempDetections.sources)
1953 minFootprints = max(self.config.star_background_min_footprints,
1954 int(self.config.star_background_peak_fraction*tempDetections.numPosPeaks))
1955 minFootprints = min(200, minFootprints)
1956 detected_fraction = self._compute_mask_fraction(result.exposure.mask, detected_mask_planes,
1957 bad_mask_planes)
1958 self.log.info("nIter = %d, thresh = %.2f: Fraction of pixels marked as DETECTED or "
1959 "DETECTED_NEGATIVE in star_background_detection = %.3f "
1960 "(max is %.3f; min is %.3f) nFooprint = %d (current min is %d)",
1961 nIter, starBackgroundDetectionConfig.thresholdValue,
1962 detected_fraction, maxDetFracForFinalBg, minDetFracForFinalBg,
1963 nFootprintTemp, minFootprints)
1964
1965 n_amp = len(result.exposure.detector.getAmplifiers())
1966 if doCheckPerAmpDetFraction: # detected_fraction < maxDetFracForFinalBg:
1967 n_above_max_per_amp, highest_detected_fraction_per_amp, no_zero_det_amps = \
1968 self._compute_per_amp_fraction(result.exposure, detected_fraction,
1969 detected_mask_planes, bad_mask_planes)
1970
1971 if not no_zero_det_amps:
1972 starBackgroundDetectionConfig.thresholdValue = 0.95*currentThresh
1973
1974 if (detected_fraction < maxDetFracForFinalBg and detected_fraction > minDetFracForFinalBg
1975 and n_above_max_per_amp < int(0.75*n_amp)
1976 and no_zero_det_amps
1977 and nFootprintTemp >= minFootprints):
1978 if (n_above_max_per_amp < max(1, int(0.15*n_amp))
1979 or detected_fraction < 0.85*maxDetFracForFinalBg):
1980 break
1981 else:
1982 self.log.warning("Making small tweak....")
1983 starBackgroundDetectionConfig.thresholdValue = 1.05*currentThresh
1984 self.log.warning("n_above_max_per_amp = %d (abs max is %d)", n_above_max_per_amp, int(0.75*n_amp))
1985
1986 detected_fraction = self._compute_mask_fraction(result.exposure.mask, detected_mask_planes,
1987 bad_mask_planes)
1988 self.log.info("Fraction of pixels marked as DETECTED or DETECTED_NEGATIVE is now %.5f "
1989 "(highest per amp section = %.5f)",
1990 detected_fraction, highest_detected_fraction_per_amp)
1991
1992 if detected_fraction > maxDetFracForFinalBg:
1993 result.exposure.mask = dilatedMask
1994 self.log.warning("Final fraction of pixels marked as DETECTED or DETECTED_NEGATIVE "
1995 "was too large in star_background_detection = %.3f (max = %.3f). "
1996 "Reverting to dilated mask from PSF detection...",
1997 detected_fraction, maxDetFracForFinalBg)
1998 star_background = self.star_background.run(
1999 exposure=result.exposure,
2000 backgroundToPhotometricRatio=background_to_photometric_ratio,
2001 ).background
2002 result.background.append(star_background[0])
2003
2004 median_background = np.median(result.background.getImage().array)
2005 self.log.info("New initial median_background = %.2f", median_background)
2006
2007 # Perform one more round of background subtraction that is just an
2008 # overall pedestal (order = 0). This is intended to account for
2009 # any potential gross oversubtraction imposed by the higher-order
2010 # subtraction.
2011 # Dilate DETECTED mask a bit more if it's below 50% detected.
2012 nPixToDilate = 2
2013 if detected_fraction < 0.5:
2014 dilatedMask = result.exposure.mask.clone()
2015 for maskName in detected_mask_planes:
2016 # Compute the grown detection mask plane using SpanSet
2017 detectedMaskBit = dilatedMask.getPlaneBitMask(maskName)
2018 detectedMaskSpanSet = SpanSet.fromMask(dilatedMask, detectedMaskBit)
2019 detectedMaskSpanSet = detectedMaskSpanSet.dilated(nPixToDilate)
2020 detectedMaskSpanSet = detectedMaskSpanSet.clippedTo(dilatedMask.getBBox())
2021 # Clear the detected mask plane
2022 detectedMask = dilatedMask.getMaskPlane(maskName)
2023 dilatedMask.clearMaskPlane(detectedMask)
2024 # Set the mask plane to the dilated one
2025 detectedMaskSpanSet.setMask(dilatedMask, detectedMaskBit)
2026
2027 detected_fraction_dilated = self._compute_mask_fraction(dilatedMask,
2028 detected_mask_planes,
2029 bad_mask_planes)
2030 result.exposure.mask = dilatedMask
2031 self.log.debug("detected_fraction_orig = %.3f detected_fraction_dilated = %.3f",
2032 detected_fraction_orig, detected_fraction_dilated)
2033
2034 pedestalBackgroundConfig = lsst.meas.algorithms.SubtractBackgroundConfig()
2035 for maskName in bgIgnoreMasksToAdd:
2036 if (maskName in result.exposure.mask.getMaskPlaneDict().keys()
2037 and maskName not in pedestalBackgroundConfig.ignoredPixelMask):
2038 pedestalBackgroundConfig.ignoredPixelMask += [maskName]
2039 pedestalBackgroundConfig.statisticsProperty = "MEDIAN"
2040 pedestalBackgroundConfig.approxOrderX = 0
2041 pedestalBackgroundConfig.binSize = 64
2042 pedestalBackgroundTask = lsst.meas.algorithms.SubtractBackgroundTask(config=pedestalBackgroundConfig)
2043 pedestalBackgroundList = pedestalBackgroundTask.run(
2044 exposure=result.exposure,
2045 background=result.background,
2046 backgroundToPhotometricRatio=background_to_photometric_ratio,
2047 ).background
2048 # Isolate the final pedestal background to log the computed value.
2049 pedestalBackground = afwMath.BackgroundList()
2050 pedestalBackground.append(pedestalBackgroundList[1])
2051 pedestalBackgroundLevel = pedestalBackground.getImage().array[0, 0]
2052 self.log.info("Subtracted pedestalBackgroundLevel = %.4f", pedestalBackgroundLevel)
2053
2054 # Clear detected mask plane before final round of detection
2055 mask = result.exposure.mask
2056 for mp in detected_mask_planes:
2057 if mp not in mask.getMaskPlaneDict():
2058 mask.addMaskPlane(mp)
2059 mask &= ~mask.getPlaneBitMask(detected_mask_planes)
2060
2061 return result
The photometric calibration of an exposure.
Definition PhotoCalib.h:114
Pass parameters to a Statistics object.
Definition Statistics.h:83
Pass parameters to algorithms that match list of sources.
Definition Match.h:45
An integer coordinate rectangle.
Definition Box.h:55
__init__(self, n_sources, psf_shape_ixx, psf_shape_iyy, psf_shape_ixy, psf_size)
runQuantum(self, butlerQC, inputRefs, outputRefs)
_compute_per_amp_fraction(self, exposure, detected_fraction, mask_planes, bad_mask_planes)
_update_wcs_with_camera_model(self, exposure, cameraModel)
_find_stars(self, exposure, background, id_generator, background_to_photometric_ratio=None, adaptive_det_res_struct=None, num_astrometry_matches=None)
_remeasure_star_background(self, result, background_to_photometric_ratio=None)
_match_psf_stars(self, psf_stars, stars, psfSigma=None)
_apply_illumination_correction(self, exposure, background_flat, illumination_correction)
_measure_aperture_correction(self, exposure, bright_sources)
_apply_photometry(self, exposure, background, background_to_photometric_ratio=None)
run(self, *, exposures, id_generator=None, result=None, background_flat=None, illumination_correction=None, camera_model=None)
__init__(self, initial_stars_schema=None, **kwargs)
_compute_psf(self, exposure, id_generator, background_to_photometric_ratio=None)
_summarize(self, exposure, stars, background)
_compute_mask_fraction(self, mask, mask_planes, bad_mask_planes)
Statistics makeStatistics(lsst::afw::image::Image< Pixel > const &img, lsst::afw::image::Mask< image::MaskPixel > const &msk, int const flags, StatisticsControl const &sctrl=StatisticsControl())
Handle a watered-down front-end to the constructor (no variance)
Definition Statistics.h:361
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
void updateSourceCoords(geom::SkyWcs const &wcs, SourceCollection &sourceList, bool include_covariance=true)
Update sky coordinates in a collection of source objects.
Definition wcsUtils.cc:125