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