22"""Extract bright star cutouts; normalize and warp to the same pixel grid."""
24__all__ = [
"ProcessBrightStarsTask"]
26import astropy.units
as u
36 stringToStatisticsProperty,
39from lsst.geom import AffineTransform, Box2I, Extent2I, Point2D, Point2I, SpherePoint, radians
44from lsst.pipe.base import PipelineTask, PipelineTaskConfig, PipelineTaskConnections, Struct
45from lsst.pipe.base.connectionTypes
import Input, Output, PrerequisiteInput
46from lsst.utils.timer
import timeMethod
50 """Connections for ProcessBrightStarsTask."""
52 inputExposure = Input(
53 doc=
"Input exposure from which to extract bright star stamps",
55 storageClass=
"ExposureF",
56 dimensions=(
"visit",
"detector"),
59 doc=
"Input Sky Correction to be subtracted from the calexp if doApplySkyCorr=True",
61 storageClass=
"Background",
62 dimensions=(
"instrument",
"visit",
"detector"),
64 refCat = PrerequisiteInput(
65 doc=
"Reference catalog that contains bright star positions",
66 name=
"gaia_dr2_20200414",
67 storageClass=
"SimpleCatalog",
68 dimensions=(
"skypix",),
72 brightStarStamps = Output(
73 doc=
"Set of preprocessed postage stamps, each centered on a single bright star.",
74 name=
"brightStarStamps",
75 storageClass=
"BrightStarStamps",
76 dimensions=(
"visit",
"detector"),
81 if not config.doApplySkyCorr:
82 self.inputs.remove(
"skyCorr")
86 """Configuration parameters for ProcessBrightStarsTask."""
90 doc=
"Magnitude limit, in Gaia G; all stars brighter than this value will be processed.",
93 stampSize = ListField(
95 doc=
"Size of the stamps to be extracted, in pixels.",
98 modelStampBuffer = Field(
101 "'Buffer' factor to be applied to determine the size of the stamp the processed stars will be "
102 "saved in. This will also be the size of the extended PSF model."
106 doRemoveDetected = Field(
108 doc=
"Whether DETECTION footprints, other than that for the central object, should be changed to BAD.",
111 doApplyTransform = Field(
113 doc=
"Apply transform to bright star stamps to correct for optical distortions?",
116 warpingKernelName = ChoiceField(
118 doc=
"Warping kernel",
121 "bilinear":
"bilinear interpolation",
122 "lanczos3":
"Lanczos kernel of order 3",
123 "lanczos4":
"Lanczos kernel of order 4",
124 "lanczos5":
"Lanczos kernel of order 5",
127 annularFluxRadii = ListField(
129 doc=
"Inner and outer radii of the annulus used to compute AnnularFlux for normalization, in pixels.",
132 annularFluxStatistic = ChoiceField(
134 doc=
"Type of statistic to use to compute annular flux.",
139 "MEANCLIP":
"clipped mean",
142 numSigmaClip = Field(
144 doc=
"Sigma for outlier rejection; ignored if annularFluxStatistic != 'MEANCLIP'.",
149 doc=
"Number of iterations of outlier rejection; ignored if annularFluxStatistic != 'MEANCLIP'.",
152 badMaskPlanes = ListField(
154 doc=
"Mask planes that identify pixels to not include in the computation of the annular flux.",
155 default=(
"BAD",
"CR",
"CROSSTALK",
"EDGE",
"NO_DATA",
"SAT",
"SUSPECT",
"UNMASKEDNAN"),
157 minValidAnnulusFraction = Field(
159 doc=
"Minumum number of valid pixels that must fall within the annulus for the bright star to be "
160 "saved for subsequent generation of a PSF.",
163 doApplySkyCorr = Field(
165 doc=
"Apply full focal plane sky correction before extracting stars?",
168 discardNanFluxStars = Field(
170 doc=
"Should stars with NaN annular flux be discarded?",
173 refObjLoader = ConfigField(
174 dtype=LoadReferenceObjectsConfig,
175 doc=
"Reference object loader for astrometric calibration.",
180 """The description of the parameters for this Task are detailed in
181 :lsst-task:`~lsst.pipe.base.PipelineTask`.
185 initInputs : `Unknown`
187 Additional positional arguments.
189 Additional keyword arguments.
193 `ProcessBrightStarsTask` is used to extract, process,
and store small
194 image cut-outs (
or "postage stamps") around bright stars. It relies on
195 three methods, called
in succession:
198 Find bright stars within the exposure using a reference catalog
and
199 extract a stamp centered on each.
201 Shift
and warp each stamp to remove optical distortions
and sample all
202 stars on the same pixel grid.
203 `measureAndNormalize`
204 Compute the flux of an object
in an annulus
and normalize it. This
is
205 required to normalize each bright star stamp
as their central pixels
206 are likely saturated
and/
or contain ghosts,
and cannot be used.
209 ConfigClass = ProcessBrightStarsConfig
210 _DefaultName = "processBrightStars"
212 def __init__(self, butler=None, initInputs=None, *args, **kwargs):
216 int(self.config.stampSize[0] * self.config.modelStampBuffer),
217 int(self.config.stampSize[1] * self.config.modelStampBuffer),
227 if butler
is not None:
228 self.makeSubtask(
"refObjLoader", butler=butler)
231 """Apply correction to the sky background level.
233 Sky corrections can be generated using the ``SkyCorrectionTask``.
234 As the sky model generated there extends over the full focal plane,
235 this should produce a more optimal sky subtraction solution.
241 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList`, optional
242 Full focal plane sky correction
from ``SkyCorrectionTask``.
246 This method modifies the input ``calexp``
in-place.
248 if isinstance(calexp, Exposure):
249 calexp = calexp.getMaskedImage()
250 calexp -= skyCorr.getImage()
253 """Read the position of bright stars within an input exposure using a
254 refCat and extract them.
258 inputExposure : `~lsst.afw.image.ExposureF`
259 The image
from which bright star stamps should be extracted.
260 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional
261 Loader to find objects within a reference catalog.
265 result : `~lsst.pipe.base.Struct`
266 Results
as a struct
with attributes:
269 Postage stamps (`list`).
271 Corresponding coords to each star
's center, in pixels (`list`).
273 Corresponding (Gaia) G magnitudes (`list`).
275 Corresponding unique Gaia identifiers (`np.ndarray`).
277 if refObjLoader
is None:
278 refObjLoader = self.refObjLoader
283 wcs = inputExposure.getWcs()
285 inputIm = inputExposure.maskedImage
286 inputExpBBox = inputExposure.getBBox()
289 dilatationExtent = Extent2I(np.array(self.config.stampSize) // 2)
291 withinCalexp = refObjLoader.loadPixelBox(
292 inputExpBBox.dilatedBy(dilatationExtent),
294 filterName=
"phot_g_mean",
296 refCat = withinCalexp.refCat
298 fluxLimit = ((self.config.magLimit * u.ABmag).to(u.nJy)).to_value()
299 GFluxes = np.array(refCat[
"phot_g_mean_flux"])
300 bright = GFluxes > fluxLimit
302 allGMags = [((gFlux * u.nJy).to(u.ABmag)).to_value()
for gFlux
in GFluxes[bright]]
303 allIds = refCat.columns.extract(
"id", where=bright)[
"id"]
304 selectedColumns = refCat.columns.extract(
"coord_ra",
"coord_dec", where=bright)
305 for j, (ra, dec)
in enumerate(zip(selectedColumns[
"coord_ra"], selectedColumns[
"coord_dec"])):
307 cpix = wcs.skyToPixel(sp)
309 starIm = inputExposure.getCutout(sp, Extent2I(self.config.stampSize))
310 except InvalidParameterError:
312 bboxCorner = np.array(cpix) - np.array(self.config.stampSize) / 2
314 idealBBox =
Box2I(Point2I(bboxCorner), Extent2I(self.config.stampSize))
315 clippedStarBBox =
Box2I(idealBBox)
316 clippedStarBBox.clip(inputExpBBox)
317 if clippedStarBBox.getArea() > 0:
320 starIm = ExposureF(bbox=idealBBox)
321 starIm.image[:] = np.nan
322 starIm.mask.set(inputExposure.mask.getPlaneBitMask(
"NO_DATA"))
324 clippedIm = inputIm.Factory(inputIm, clippedStarBBox)
325 starIm.maskedImage[clippedStarBBox] = clippedIm
327 starIm.setDetector(inputExposure.getDetector())
328 starIm.setWcs(inputExposure.getWcs())
331 if self.config.doRemoveDetected:
333 detThreshold =
Threshold(starIm.mask.getPlaneBitMask(
"DETECTED"), Threshold.BITMASK)
335 allFootprints = omask.getFootprints()
337 for fs
in allFootprints:
338 if not fs.contains(Point2I(cpix)):
339 otherFootprints.append(fs)
340 nbMatchingFootprints = len(allFootprints) - len(otherFootprints)
341 if not nbMatchingFootprints == 1:
343 "Failed to uniquely identify central DETECTION footprint for star "
344 "%s; found %d footprints instead.",
346 nbMatchingFootprints,
348 omask.setFootprints(otherFootprints)
349 omask.setMask(starIm.mask,
"BAD")
350 starIms.append(starIm)
351 pixCenters.append(cpix)
352 GMags.append(allGMags[j])
353 ids.append(allIds[j])
354 return Struct(starIms=starIms, pixCenters=pixCenters, GMags=GMags, gaiaIds=ids)
357 """Warps and shifts all given stamps so they are sampled on the same
358 pixel grid and centered on the central pixel. This includes rotating
359 the stamp depending on detector orientation.
363 stamps : `Sequence` [`~lsst.afw.image.ExposureF`]
364 Image cutouts centered on a single object.
366 Positions of each object
's center (from the refCat) in pixels.
370 result : `~lsst.pipe.base.Struct`
371 Results as a struct
with attributes:
374 Stamps of warped stars.
377 The corresponding Transform
from the initial star stamp
378 to the common model grid.
381 Coordinates of the bottom-left pixels of each stamp,
385 The number of 90 degrees rotations required to compensate
for
386 detector orientation.
398 det = stamps[0].getDetector()
400 if self.config.doApplyTransform:
401 pixToTan = det.getTransform(PIXELS, TAN_PIXELS)
403 pixToTan = makeIdentityTransform()
405 possibleRots = np.array([k * np.pi / 2
for k
in range(4)])
407 yaw = det.getOrientation().getYaw()
408 nb90Rots = np.argmin(np.abs(possibleRots - float(yaw)))
411 warpedStars, warpTransforms, xy0s = [], [], []
412 for star, cent
in zip(stamps, pixCenters):
415 bottomLeft = Point2D(star.image.getXY0())
416 newBottomLeft = pixToTan.applyForward(bottomLeft)
417 newBottomLeft.setX(newBottomLeft.getX() - bufferPix[0] / 2)
418 newBottomLeft.setY(newBottomLeft.getY() - bufferPix[1] / 2)
420 newBottomLeft = Point2I(newBottomLeft)
422 destImage.setXY0(newBottomLeft)
423 xy0s.append(newBottomLeft)
426 newCenter = pixToTan.applyForward(cent)
428 self.
modelCenter[0] + newBottomLeft[0] - newCenter[0],
429 self.
modelCenter[1] + newBottomLeft[1] - newCenter[1],
432 shiftTransform = makeTransform(affineShift)
435 starWarper = pixToTan.then(shiftTransform)
438 goodPix = warpImage(destImage, star.getMaskedImage(), starWarper, warpCont)
440 self.log.debug(
"Warping of a star failed: no good pixel in output")
443 destImage.setXY0(0, 0)
447 destImage = rotateImageBy90(destImage, nb90Rots)
448 warpedStars.append(destImage.clone())
449 warpTransforms.append(starWarper)
450 return Struct(warpedStars=warpedStars, warpTransforms=warpTransforms, xy0s=xy0s, nb90Rots=nb90Rots)
453 def run(self, inputExposure, refObjLoader=None, dataId=None, skyCorr=None):
454 """Identify bright stars within an exposure using a reference catalog,
455 extract stamps around each, then preprocess them. The preprocessing
456 steps are: shifting, warping and potentially rotating them to the same
457 pixel grid; computing their annular flux
and normalizing them.
461 inputExposure : `~lsst.afw.image.ExposureF`
462 The image
from which bright star stamps should be extracted.
463 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional
464 Loader to find objects within a reference catalog.
465 dataId : `dict`
or `~lsst.daf.butler.DataCoordinate`
466 The dataId of the exposure (
and detector) bright stars should be
468 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList`, optional
469 Full focal plane sky correction obtained by `SkyCorrectionTask`.
473 result : `~lsst.pipe.base.Struct`
474 Results
as a struct
with attributes:
479 if self.config.doApplySkyCorr:
481 "Applying sky correction to exposure %s (exposure will be modified in-place).", dataId
484 self.log.info(
"Extracting bright stars from exposure %s", dataId)
486 extractedStamps = self.
extractStamps(inputExposure, refObjLoader=refObjLoader)
487 if not extractedStamps.starIms:
488 self.log.info(
"No suitable bright star found.")
492 "Applying warp and/or shift to %i star stamps from exposure %s.",
493 len(extractedStamps.starIms),
496 warpOutputs = self.
warpStamps(extractedStamps.starIms, extractedStamps.pixCenters)
497 warpedStars = warpOutputs.warpedStars
498 xy0s = warpOutputs.xy0s
502 archive_element=transform,
504 gaiaGMag=extractedStamps.GMags[j],
505 gaiaId=extractedStamps.gaiaIds[j],
506 minValidAnnulusFraction=self.config.minValidAnnulusFraction,
508 for j, (warp, transform)
in enumerate(zip(warpedStars, warpOutputs.warpTransforms))
512 "Computing annular flux and normalizing %i bright stars from exposure %s.",
518 statsControl.setNumSigmaClip(self.config.numSigmaClip)
519 statsControl.setNumIter(self.config.numIter)
520 innerRadius, outerRadius = self.config.annularFluxRadii
521 statsFlag = stringToStatisticsProperty(self.config.annularFluxStatistic)
522 brightStarStamps = BrightStarStamps.initAndNormalize(
524 innerRadius=innerRadius,
525 outerRadius=outerRadius,
526 nb90Rots=warpOutputs.nb90Rots,
529 statsControl=statsControl,
531 badMaskPlanes=self.config.badMaskPlanes,
532 discardNanFluxObjects=(self.config.discardNanFluxStars),
534 return Struct(brightStarStamps=brightStarStamps)
537 inputs = butlerQC.get(inputRefs)
538 inputs[
"dataId"] =
str(butlerQC.quantum.dataId)
539 refObjLoader = ReferenceObjectLoader(
540 dataIds=[ref.datasetRef.dataId
for ref
in inputRefs.refCat],
541 refCats=inputs.pop(
"refCat"),
542 name=self.config.connections.refCat,
543 config=self.config.refObjLoader,
545 output = self.
run(**inputs, refObjLoader=refObjLoader)
547 butlerQC.put(output, outputRefs)
def __init__(self, *config=None)
def runQuantum(self, butlerQC, inputRefs, outputRefs)
def warpStamps(self, stamps, pixCenters)
def run(self, inputExposure, refObjLoader=None, dataId=None, skyCorr=None)
def applySkyCorr(self, calexp, skyCorr)
def extractStamps(self, inputExposure, refObjLoader=None)
def __init__(self, butler=None, initInputs=None, *args, **kwargs)