22"""Retrieve extended PSF model and subtract bright stars at visit level."""
24__all__ = [
"SubtractBrightStarsConnections",
"SubtractBrightStarsConfig",
"SubtractBrightStarsTask"]
27from functools
import reduce
28from operator
import ior
38 stringToStatisticsProperty,
41from lsst.geom import Box2I, Point2D, Point2I
45from lsst.pipe.base
import PipelineTask, PipelineTaskConfig, PipelineTaskConnections, Struct
46from lsst.pipe.base.connectionTypes
import Input, Output, PrerequisiteInput
49logger = logging.getLogger(__name__)
53 PipelineTaskConnections,
54 dimensions=(
"instrument",
"visit",
"detector"),
56 "outputExposureName":
"brightStar_subtracted",
57 "outputBackgroundName":
"brightStars",
58 "badStampsName":
"brightStars",
61 inputExposure = Input(
62 doc=
"Input exposure from which to subtract bright star stamps.",
64 storageClass=
"ExposureF",
70 inputBrightStarStamps = Input(
71 doc=
"Set of preprocessed postage stamps, each centered on a single bright star.",
72 name=
"brightStarStamps",
73 storageClass=
"BrightStarStamps",
79 inputExtendedPsf = Input(
80 doc=
"Extended PSF model.",
82 storageClass=
"ExtendedPsf",
86 doc=
"Input Sky Correction to be subtracted from the calexp if ``doApplySkyCorr``=True.",
88 storageClass=
"Background",
95 refCat = PrerequisiteInput(
96 doc=
"Reference catalog that contains bright star positions",
97 name=
"gaia_dr2_20200414",
98 storageClass=
"SimpleCatalog",
99 dimensions=(
"skypix",),
103 outputExposure = Output(
104 doc=
"Exposure with bright stars subtracted.",
105 name=
"{outputExposureName}_calexp",
106 storageClass=
"ExposureF",
112 outputBackgroundExposure = Output(
113 doc=
"Exposure containing only the modelled bright stars.",
114 name=
"{outputBackgroundName}_calexp_background",
115 storageClass=
"ExposureF",
121 outputBadStamps = Output(
122 doc=
"The stamps that are not normalized and consequently not subtracted from the exposure.",
123 name=
"{badStampsName}_unsubtracted_stamps",
124 storageClass=
"BrightStarStamps",
131 def __init__(self, *, config=None):
132 super().__init__(config=config)
133 if not config.doApplySkyCorr:
134 self.inputs.remove(
"skyCorr")
137class SubtractBrightStarsConfig(PipelineTaskConfig, pipelineConnections=SubtractBrightStarsConnections):
138 """Configuration parameters for SubtractBrightStarsTask"""
140 doWriteSubtractor = Field[bool](
141 doc=
"Should an exposure containing all bright star models be written to disk?",
144 doWriteSubtractedExposure = Field[bool](
145 doc=
"Should an exposure with bright stars subtracted be written to disk?",
148 magLimit = Field[float](
149 doc=
"Magnitude limit, in Gaia G; all stars brighter than this value will be subtracted",
152 minValidAnnulusFraction = Field[float](
153 doc=
"Minimum number of valid pixels that must fall within the annulus for the bright star to be "
154 "saved for subsequent generation of a PSF.",
157 numSigmaClip = Field[float](
158 doc=
"Sigma for outlier rejection; ignored if annularFluxStatistic != 'MEANCLIP'.",
161 numIter = Field[int](
162 doc=
"Number of iterations of outlier rejection; ignored if annularFluxStatistic != 'MEANCLIP'.",
165 warpingKernelName = ChoiceField[str](
166 doc=
"Warping kernel",
169 "bilinear":
"bilinear interpolation",
170 "lanczos3":
"Lanczos kernel of order 3",
171 "lanczos4":
"Lanczos kernel of order 4",
172 "lanczos5":
"Lanczos kernel of order 5",
173 "lanczos6":
"Lanczos kernel of order 6",
174 "lanczos7":
"Lanczos kernel of order 7",
177 scalingType = ChoiceField[str](
178 doc=
"How the model should be scaled to each bright star; implemented options are "
179 "`annularFlux` to reuse the annular flux of each stamp, or `leastSquare` to perform "
180 "least square fitting on each pixel with no bad mask plane set.",
181 default=
"leastSquare",
183 "annularFlux":
"reuse BrightStarStamp annular flux measurement",
184 "leastSquare":
"find least square scaling factor",
187 annularFluxStatistic = ChoiceField[str](
188 doc=
"Type of statistic to use to compute annular flux.",
193 "MEANCLIP":
"clipped mean",
196 badMaskPlanes = ListField[str](
197 doc=
"Mask planes that, if set, lead to associated pixels not being included in the computation of "
198 "the scaling factor (`BAD` should always be included). Ignored if scalingType is `annularFlux`, "
199 "as the stamps are expected to already be normalized.",
203 default=(
"BAD",
"CR",
"CROSSTALK",
"EDGE",
"NO_DATA",
"SAT",
"SUSPECT",
"UNMASKEDNAN"),
205 subtractionBox = ListField[int](
206 doc=
"Size of the stamps to be extracted, in pixels.",
209 subtractionBoxBuffer = Field[float](
211 "'Buffer' (multiplicative) factor to be applied to determine the size of the stamp the "
212 "processed stars will be saved in. This is also the size of the extended PSF model. The buffer "
213 "region is masked and contain no data and subtractionBox determines the region where contains "
218 doApplySkyCorr = Field[bool](
219 doc=
"Apply full focal plane sky correction before extracting stars?",
222 refObjLoader = ConfigField[LoadReferenceObjectsConfig](
223 doc=
"Reference object loader for astrometric calibration.",
227class SubtractBrightStarsTask(PipelineTask):
228 """Use an extended PSF model to subtract bright stars from a calibrated
229 exposure (i.e. at single-visit level).
231 This task uses both a set of bright star stamps produced by
232 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask`
233 and an extended PSF model produced by
234 `~lsst.pipe.tasks.extended_psf.MeasureExtendedPsfTask`.
237 ConfigClass = SubtractBrightStarsConfig
238 _DefaultName =
"subtractBrightStars"
240 def __init__(self, *args, **kwargs):
241 super().__init__(*args, **kwargs)
243 self.statsControl, self.statsFlag =
None,
None
247 def runQuantum(self, butlerQC, inputRefs, outputRefs):
249 inputs = butlerQC.get(inputRefs)
250 dataId = butlerQC.quantum.dataId
251 refObjLoader = ReferenceObjectLoader(
252 dataIds=[ref.datasetRef.dataId
for ref
in inputRefs.refCat],
253 refCats=inputs.pop(
"refCat"),
254 name=self.config.connections.refCat,
255 config=self.config.refObjLoader,
257 subtractor, _, badStamps = self.run(**inputs, dataId=dataId, refObjLoader=refObjLoader)
258 if self.config.doWriteSubtractedExposure:
259 outputExposure = inputs[
"inputExposure"].clone()
260 outputExposure.image -= subtractor.image
262 outputExposure =
None
263 outputBackgroundExposure = subtractor
if self.config.doWriteSubtractor
else None
269 outputExposure=outputExposure,
270 outputBackgroundExposure=outputBackgroundExposure,
271 outputBadStamps=badStamps,
273 butlerQC.put(output, outputRefs)
276 self, inputExposure, inputBrightStarStamps, inputExtendedPsf, dataId, skyCorr=None, refObjLoader=None
278 """Iterate over all bright stars in an exposure to scale the extended
279 PSF model before subtracting bright stars.
283 inputExposure : `~lsst.afw.image.ExposureF`
284 The image from which bright stars should be subtracted.
285 inputBrightStarStamps :
286 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps`
287 Set of stamps centered on each bright star to be subtracted,
289 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask`.
290 inputExtendedPsf : `~lsst.pipe.tasks.extended_psf.ExtendedPsf`
291 Extended PSF model, produced by
292 `~lsst.pipe.tasks.extended_psf.MeasureExtendedPsfTask`.
293 dataId : `dict` or `~lsst.daf.butler.DataCoordinate`
294 The dataId of the exposure (and detector) bright stars should be
296 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList`, optional
297 Full focal plane sky correction, obtained by running
298 `~lsst.pipe.tasks.skyCorrection.SkyCorrectionTask`. If
299 `doApplySkyCorr` is set to `True`, `skyCorr` cannot be `None`.
300 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional
301 Loader to find objects within a reference catalog.
305 subtractorExp : `~lsst.afw.image.ExposureF`
306 An Exposure containing a scaled bright star model fit to every
307 bright star profile; its image can then be subtracted from the
309 invImages : `list` [`~lsst.afw.image.MaskedImageF`]
310 A list of small images ("stamps") containing the model, each scaled
311 to its corresponding input bright star.
313 self.inputExpBBox = inputExposure.getBBox()
314 if self.config.doApplySkyCorr
and (skyCorr
is not None):
316 "Applying sky correction to exposure %s (exposure will be modified in-place).", dataId
318 self.applySkyCorr(inputExposure, skyCorr)
322 subtractorExp = ExposureF(bbox=inputExposure.getBBox())
323 subtractor = subtractorExp.maskedImage
327 self.modelStampSize = self.model.getDimensions()
329 self.inv90Rots = 4 - inputBrightStarStamps.nb90Rots % 4
330 self.model = rotateImageBy90(self.model, self.inv90Rots)
332 brightStarList = self.makeBrightStarList(inputBrightStarStamps, inputExposure, refObjLoader)
334 subtractor, invImages = self.buildSubtractor(
335 inputBrightStarStamps, subtractor, invImages, multipleAnnuli=
False
338 self.setMissedStarsStatsControl()
341 innerRadius = inputBrightStarStamps._innerRadius
342 outerRadius = inputBrightStarStamps._outerRadius
343 brightStarStamps, badStamps = BrightStarStamps.initAndNormalize(
345 innerRadius=innerRadius,
346 outerRadius=outerRadius,
347 nb90Rots=self.warpOutputs.nb90Rots,
348 imCenter=self.warper.modelCenter,
350 statsControl=self.missedStatsControl,
351 statsFlag=self.missedStatsFlag,
352 badMaskPlanes=self.warper.config.badMaskPlanes,
353 discardNanFluxObjects=
False,
357 self.psf_annular_fluxes = self.findPsfAnnularFluxes(brightStarStamps)
358 subtractor, invImages = self.buildSubtractor(
359 brightStarStamps, subtractor, invImages, multipleAnnuli=
True
365 return subtractorExp, invImages, badStamps
367 def _setUpStatistics(self, exampleMask):
368 """Configure statistics control and flag, for use if ``scalingType`` is
371 if self.config.scalingType ==
"leastSquare":
375 (exampleMask.getPlaneBitMask(bm)
for bm
in self.config.badMaskPlanes),
380 self.statsFlag = stringToStatisticsProperty(
"SUM")
382 def applySkyCorr(self, calexp, skyCorr):
383 """Apply correction to the sky background level.
384 Sky corrections can be generated via the SkyCorrectionTask within the
385 pipe_tools module. Because the sky model used by that code extends over
386 the entire focal plane, this can produce better sky subtraction.
387 The calexp is updated in-place.
391 calexp : `~lsst.afw.image.Exposure` or `~lsst.afw.image.MaskedImage`
393 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList`
394 Full focal plane sky correction, obtained by running
395 `~lsst.pipe.tasks.skyCorrection.SkyCorrectionTask`.
397 if isinstance(calexp, Exposure):
398 calexp = calexp.getMaskedImage()
399 calexp -= skyCorr.getImage()
401 def scaleModel(self, model, star, inPlace=True, nb90Rots=0, psf_annular_flux=1.0):
402 """Compute scaling factor to be applied to the extended PSF so that its
403 amplitude matches that of an individual star.
407 model : `~lsst.afw.image.MaskedImageF`
408 The extended PSF model, shifted (and potentially warped) to match
409 the bright star position.
410 star : `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp`
411 A stamp centered on the bright star to be subtracted.
413 Whether the model should be scaled in place. Default is `True`.
415 The number of 90-degrees rotations to apply to the star stamp.
416 psf_annular_flux: `float`, optional
417 The annular flux of the PSF model at the radius where the flux of
418 the given star is determined. This is 1 for stars present in
419 inputBrightStarStamps, but can be different for stars that are
420 missing from inputBrightStarStamps.
424 scalingFactor : `float`
425 The factor by which the model image should be multiplied for it
426 to be scaled to the input bright star.
428 if self.config.scalingType ==
"annularFlux":
429 scalingFactor = star.annularFlux * psf_annular_flux
430 elif self.config.scalingType ==
"leastSquare":
431 if self.statsControl
is None:
432 self._setUpStatistics(star.stamp_im.mask)
433 starIm = star.stamp_im.clone()
435 starIm = rotateImageBy90(starIm, nb90Rots)
437 starIm *= star.annularFlux
441 xy.image.array *= model.image.array
443 xx.image.array = model.image.array**2
445 xySum = makeStatistics(xy, self.statsFlag, self.statsControl).getValue()
446 xxSum = makeStatistics(xx, self.statsFlag, self.statsControl).getValue()
447 scalingFactor = xySum / xxSum
if xxSum
else 1
449 model.image *= scalingFactor
452 def _overrideWarperConfig(self):
453 """Override the warper config with the config of this task.
455 This override is necessary for stars that are missing from the
456 inputBrightStarStamps object but still need to be subtracted.
459 self.warper.config.minValidAnnulusFraction = self.config.minValidAnnulusFraction
460 self.warper.config.numSigmaClip = self.config.numSigmaClip
461 self.warper.config.numIter = self.config.numIter
462 self.warper.config.annularFluxStatistic = self.config.annularFluxStatistic
463 self.warper.config.badMaskPlanes = self.config.badMaskPlanes
464 self.warper.config.stampSize = self.config.subtractionBox
465 self.warper.modelStampBuffer = self.config.subtractionBoxBuffer
466 self.warper.config.magLimit = self.config.magLimit
467 self.warper.setModelStamp()
469 def setMissedStarsStatsControl(self):
470 """Configure statistics control for processing missing stars from
471 inputBrightStarStamps.
474 numSigmaClip=self.warper.config.numSigmaClip,
475 numIter=self.warper.config.numIter,
477 self.missedStatsFlag = stringToStatisticsProperty(self.warper.config.annularFluxStatistic)
479 def setWarpTask(self):
480 """Create an instance of ProcessBrightStarsTask that will be used to
481 produce stamps of stars to be subtracted.
484 self._overrideWarperConfig()
485 self.warper.modelCenter = self.modelStampSize[0] // 2, self.modelStampSize[1] // 2
487 def makeBrightStarList(self, inputBrightStarStamps, inputExposure, refObjLoader):
488 """Make a list of bright stars that are missing from
489 inputBrightStarStamps to be subtracted.
493 inputBrightStarStamps :
494 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps`
495 Set of stamps centered on each bright star to be subtracted,
497 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask`.
498 inputExposure : `~lsst.afw.image.ExposureF`
499 The image from which bright stars should be subtracted.
500 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional
501 Loader to find objects within a reference catalog.
507 `lsst.meas.algorithms.brightStarStamps.BrightStarStamp` of stars to
511 missedStars = self.warper.extractStamps(
512 inputExposure, refObjLoader=refObjLoader, inputBrightStarStamps=inputBrightStarStamps
514 if missedStars.starStamps:
515 self.warpOutputs = self.warper.warpStamps(missedStars.starStamps, missedStars.pixCenters)
519 archive_element=transform,
520 position=self.warpOutputs.xy0s[j],
521 gaiaGMag=missedStars.gMags[j],
522 gaiaId=missedStars.gaiaIds[j],
523 minValidAnnulusFraction=self.warper.config.minValidAnnulusFraction,
525 for j, (warp, transform)
in enumerate(
526 zip(self.warpOutputs.warpedStars, self.warpOutputs.warpTransforms)
531 return brightStarList
533 def initAnnulusImage(self):
534 """Initialize an annulus image of the given star.
538 annulusImage : `~lsst.afw.image.MaskedImageF`
539 The initialized annulus image.
541 maskPlaneDict = self.model.mask.getMaskPlaneDict()
542 annulusImage = MaskedImageF(self.modelStampSize, planeDict=maskPlaneDict)
543 annulusImage.mask.array[:] = 2 ** maskPlaneDict[
"NO_DATA"]
546 def createAnnulus(self, brightStarStamp):
547 """Create a circular annulus around the given star.
549 The circular annulus is set based on the inner and outer optimal radii.
550 These radii describe the annulus where the flux of the star is found.
551 The aim is to create the same annulus for the PSF model, eventually
552 measuring the model flux around that annulus.
553 An optimal radius usually differs from the radius where the PSF model
559 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp`
560 A stamp of a bright star to be subtracted.
564 annulus : `~lsst.afw.image.MaskedImageF`
565 An annulus of the given star.
568 outerCircle = SpanSet.fromShape(
569 brightStarStamp.optimalOuterRadius, Stencil.CIRCLE, offset=self.warper.modelCenter
571 innerCircle = SpanSet.fromShape(
572 brightStarStamp.optimalInnerRadius, Stencil.CIRCLE, offset=self.warper.modelCenter
574 annulus = outerCircle.intersectNot(innerCircle)
577 def applyStatsControl(self, annulusImage):
578 """Apply statistics control to the PSF annulus image.
582 annulusImage : `~lsst.afw.image.MaskedImageF`
583 An image containing an annulus of the given model.
588 The annular flux of the PSF model at the radius where the flux of
589 the given star is determined.
592 ior, (annulusImage.mask.getPlaneBitMask(bm)
for bm
in self.warper.config.badMaskPlanes)
594 self.missedStatsControl.setAndMask(andMask)
595 annulusStat = makeStatistics(annulusImage, self.missedStatsFlag, self.missedStatsControl)
596 return annulusStat.getValue()
598 def findPsfAnnularFlux(self, brightStarStamp, maskedModel):
599 """Find the annular flux of the PSF model within a specified annulus.
601 This flux will be used for re-scaling the PSF to the level of stars
602 with bad stamps. Stars with bad stamps are those without a flux within
603 the normalization annulus.
608 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp`
609 A stamp of a bright star to be subtracted.
610 maskedModel : `~lsst.afw.image.MaskedImageF`
611 A masked image of the PSF model.
615 annularFlux: float (between 0 and 1)
616 The annular flux of the PSF model at the radius where the flux of
617 the given star is determined.
619 annulusImage = self.initAnnulusImage()
620 annulus = self.createAnnulus(brightStarStamp)
621 annulus.copyMaskedImage(maskedModel, annulusImage)
622 annularFlux = self.applyStatsControl(annulusImage)
625 def findPsfAnnularFluxes(self, brightStarStamps):
626 """Find the annular fluxes of the given PSF model.
631 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps`
632 The stamps of stars that will be subtracted from the exposure.
636 PsfAnnularFluxes: numpy.array
637 A two column numpy.array containing annular fluxes of the PSF at
638 radii where the flux for stars exist (could be found).
642 While the PSF model is normalized at a certain radius, the annular flux
643 of a star around that radius might be impossible to find. Therefore, we
644 have to scale the PSF model considering a radius where the star has an
645 identified flux. To do that, the flux of the model should be found and
646 used to adjust the scaling step.
650 maskedModel = MaskedImageF(self.model.image)
652 maskedModel.setXY0(0, 0)
653 for star
in brightStarStamps:
654 if star.optimalOuterRadius
not in outerRadii:
655 annularFlux = self.findPsfAnnularFlux(star, maskedModel)
656 outerRadii.append(star.optimalOuterRadius)
657 annularFluxes.append(annularFlux)
658 return np.array([outerRadii, annularFluxes]).T
660 def preparePlaneModelStamp(self, brightStarStamp):
661 """Prepare the PSF plane model stamp.
663 It is called PlaneModel because, while it is a PSF model stamp that is
664 warped and rotated to the same orientation of a chosen star, it is not
665 yet scaled to the brightness level of the star.
670 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp`
671 The stamp of the star to which the PSF model will be scaled.
675 bbox: `~lsst.geom.Box2I`
676 Contains the corner coordination and the dimensions of the model
679 invImage: `~lsst.afw.image.MaskedImageF`
680 The extended PSF model, shifted (and potentially warped and
681 rotated) to match the bright star position.
686 Raised if warping of the model failed.
690 Since detectors have different orientations, the PSF model should be
691 rotated to match the orientation of the detectors in some cases. To do
692 that, the code uses the inverse of the transform that is applied to the
693 bright star stamp to match the orientation of the detector.
696 self.model.setXY0(brightStarStamp.position)
698 invTransform = brightStarStamp.archive_element.inverted()
699 invOrigin = Point2I(invTransform.applyForward(Point2D(brightStarStamp.position)))
700 bbox =
Box2I(corner=invOrigin, dimensions=self.modelStampSize)
701 invImage = MaskedImageF(bbox)
703 goodPix = warpImage(invImage, self.model, invTransform, self.warpControl)
709 f
"Warping of a model failed for star {brightStarStamp.gaiaId}: no good pixel in output."
711 return bbox, invImage
713 def addScaledModel(self, subtractor, brightStarStamp, multipleAnnuli=False):
714 """Add the scaled model of the given star to the subtractor plane.
718 subtractor : `~lsst.afw.image.MaskedImageF`
719 The full image containing the scaled model of bright stars to be
720 subtracted from the input exposure.
722 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamp`
723 The stamp of the star of which the PSF model will be scaled and
724 added to the subtractor.
725 multipleAnnuli : bool, optional
726 If true, the model should be scaled based on a flux at a radius
727 other than its normalization radius.
731 subtractor : `~lsst.afw.image.MaskedImageF`
732 The input subtractor full image with the added scaled model at the
733 given star's location in the exposure.
734 invImage: `~lsst.afw.image.MaskedImageF`
735 The extended PSF model, shifted (and potentially warped) to match
736 the bright star position.
738 bbox, invImage = self.preparePlaneModelStamp(brightStarStamp)
739 bbox.clip(self.inputExpBBox)
740 if bbox.getArea() > 0:
742 cond = self.psf_annular_fluxes[:, 0] == brightStarStamp.optimalOuterRadius
743 psf_annular_flux = self.psf_annular_fluxes[cond, 1][0]
748 nb90Rots=self.inv90Rots,
749 psf_annular_flux=psf_annular_flux,
752 self.scaleModel(invImage, brightStarStamp, inPlace=
True, nb90Rots=self.inv90Rots)
754 invImage.image.array[np.isnan(invImage.image.array)] = 0
755 subtractor[bbox] += invImage[bbox]
756 return subtractor, invImage
758 def buildSubtractor(self, brightStarStamps, subtractor, invImages, multipleAnnuli=False):
759 """Build an image containing potentially multiple scaled PSF models,
760 each at the location of a given bright star.
765 `~lsst.meas.algorithms.brightStarStamps.BrightStarStamps`
766 Set of stamps centered on each bright star to be subtracted,
768 `~lsst.pipe.tasks.processBrightStars.ProcessBrightStarsTask`.
769 subtractor : `~lsst.afw.image.MaskedImageF`
770 The Exposure that will contain the scaled model of bright stars to
771 be subtracted from the exposure.
773 A list containing extended PSF models, shifted (and potentially
774 warped) to match the bright stars positions.
775 multipleAnnuli : bool, optional
776 This will be passed to addScaledModel method, by default False.
780 subtractor : `~lsst.afw.image.MaskedImageF`
781 An Exposure containing a scaled bright star model fit to every
782 bright star profile; its image can then be subtracted from the
785 A list containing the extended PSF models, shifted (and potentially
786 warped) to match bright stars' positions.
788 for star
in brightStarStamps:
789 if star.gaiaGMag < self.config.magLimit:
792 subtractor, invImage = self.addScaledModel(subtractor, star, multipleAnnuli)
793 invImages.append(invImage)
794 except RuntimeError
as err:
796 return subtractor, invImages