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 minPixelsWithinFrame = Field(
160 "Minimum number of pixels that must fall within the stamp boundary for the bright star to be "
161 "saved when its center is beyond the exposure boundary."
165 doApplySkyCorr = Field(
167 doc=
"Apply full focal plane sky correction before extracting stars?",
170 discardNanFluxStars = Field(
172 doc=
"Should stars with NaN annular flux be discarded?",
175 refObjLoader = ConfigField(
176 dtype=LoadReferenceObjectsConfig,
177 doc=
"Reference object loader for astrometric calibration.",
182 """The description of the parameters for this Task are detailed in
183 :lsst-task:`~lsst.pipe.base.PipelineTask`.
187 initInputs : `Unknown`
189 Additional positional arguments.
191 Additional keyword arguments.
195 `ProcessBrightStarsTask` is used to extract, process,
and store small
196 image cut-outs (
or "postage stamps") around bright stars. It relies on
197 three methods, called
in succession:
200 Find bright stars within the exposure using a reference catalog
and
201 extract a stamp centered on each.
203 Shift
and warp each stamp to remove optical distortions
and sample all
204 stars on the same pixel grid.
205 `measureAndNormalize`
206 Compute the flux of an object
in an annulus
and normalize it. This
is
207 required to normalize each bright star stamp
as their central pixels
208 are likely saturated
and/
or contain ghosts,
and cannot be used.
211 ConfigClass = ProcessBrightStarsConfig
212 _DefaultName = "processBrightStars"
214 def __init__(self, butler=None, initInputs=None, *args, **kwargs):
218 int(self.config.stampSize[0] * self.config.modelStampBuffer),
219 int(self.config.stampSize[1] * self.config.modelStampBuffer),
229 if butler
is not None:
230 self.makeSubtask(
"refObjLoader", butler=butler)
233 """Apply correction to the sky background level.
235 Sky corrections can be generated using the ``SkyCorrectionTask``.
236 As the sky model generated there extends over the full focal plane,
237 this should produce a more optimal sky subtraction solution.
243 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList`, optional
244 Full focal plane sky correction
from ``SkyCorrectionTask``.
248 This method modifies the input ``calexp``
in-place.
250 if isinstance(calexp, Exposure):
251 calexp = calexp.getMaskedImage()
252 calexp -= skyCorr.getImage()
255 """Read the position of bright stars within an input exposure using a
256 refCat and extract them.
260 inputExposure : `~lsst.afw.image.ExposureF`
261 The image
from which bright star stamps should be extracted.
262 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional
263 Loader to find objects within a reference catalog.
267 result : `~lsst.pipe.base.Struct`
268 Results
as a struct
with attributes:
271 Postage stamps (`list`).
273 Corresponding coords to each star
's center, in pixels (`list`).
275 Corresponding (Gaia) G magnitudes (`list`).
277 Corresponding unique Gaia identifiers (`np.ndarray`).
279 if refObjLoader
is None:
280 refObjLoader = self.refObjLoader
285 wcs = inputExposure.getWcs()
287 inputIm = inputExposure.maskedImage
288 inputExpBBox = inputExposure.getBBox()
289 dilatationExtent = Extent2I(np.array(self.config.stampSize) - self.config.minPixelsWithinFrame)
291 withinCalexp = refObjLoader.loadPixelBox(
292 inputExpBBox.dilatedBy(dilatationExtent), wcs, filterName=
"phot_g_mean"
294 refCat = withinCalexp.refCat
296 fluxLimit = ((self.config.magLimit * u.ABmag).to(u.nJy)).to_value()
297 GFluxes = np.array(refCat[
"phot_g_mean_flux"])
298 bright = GFluxes > fluxLimit
300 allGMags = [((gFlux * u.nJy).to(u.ABmag)).to_value()
for gFlux
in GFluxes[bright]]
301 allIds = refCat.columns.extract(
"id", where=bright)[
"id"]
302 selectedColumns = refCat.columns.extract(
"coord_ra",
"coord_dec", where=bright)
303 for j, (ra, dec)
in enumerate(zip(selectedColumns[
"coord_ra"], selectedColumns[
"coord_dec"])):
305 cpix = wcs.skyToPixel(sp)
307 starIm = inputExposure.getCutout(sp, Extent2I(self.config.stampSize))
308 except InvalidParameterError:
310 bboxCorner = np.array(cpix) - np.array(self.config.stampSize) / 2
312 idealBBox =
Box2I(Point2I(bboxCorner), Extent2I(self.config.stampSize))
313 clippedStarBBox =
Box2I(idealBBox)
314 clippedStarBBox.clip(inputExpBBox)
315 if clippedStarBBox.getArea() > 0:
318 starIm = ExposureF(bbox=idealBBox)
319 starIm.image[:] = np.nan
320 starIm.mask.set(inputExposure.mask.getPlaneBitMask(
"NO_DATA"))
322 clippedIm = inputIm.Factory(inputIm, clippedStarBBox)
323 starIm.maskedImage[clippedStarBBox] = clippedIm
325 starIm.setDetector(inputExposure.getDetector())
326 starIm.setWcs(inputExposure.getWcs())
329 if self.config.doRemoveDetected:
331 detThreshold =
Threshold(starIm.mask.getPlaneBitMask(
"DETECTED"), Threshold.BITMASK)
333 allFootprints = omask.getFootprints()
335 for fs
in allFootprints:
336 if not fs.contains(Point2I(cpix)):
337 otherFootprints.append(fs)
338 nbMatchingFootprints = len(allFootprints) - len(otherFootprints)
339 if not nbMatchingFootprints == 1:
341 "Failed to uniquely identify central DETECTION footprint for star "
342 "%s; found %d footprints instead.",
344 nbMatchingFootprints,
346 omask.setFootprints(otherFootprints)
347 omask.setMask(starIm.mask,
"BAD")
348 starIms.append(starIm)
349 pixCenters.append(cpix)
350 GMags.append(allGMags[j])
351 ids.append(allIds[j])
352 return Struct(starIms=starIms, pixCenters=pixCenters, GMags=GMags, gaiaIds=ids)
355 """Warps and shifts all given stamps so they are sampled on the same
356 pixel grid and centered on the central pixel. This includes rotating
357 the stamp depending on detector orientation.
361 stamps : `Sequence` [`~lsst.afw.image.ExposureF`]
362 Image cutouts centered on a single object.
364 Positions of each object
's center (from the refCat) in pixels.
368 result : `~lsst.pipe.base.Struct`
369 Results as a struct
with attributes:
372 Stamps of warped stars.
375 The corresponding Transform
from the initial star stamp
376 to the common model grid.
379 Coordinates of the bottom-left pixels of each stamp,
383 The number of 90 degrees rotations required to compensate
for
384 detector orientation.
396 det = stamps[0].getDetector()
398 if self.config.doApplyTransform:
399 pixToTan = det.getTransform(PIXELS, TAN_PIXELS)
401 pixToTan = makeIdentityTransform()
403 possibleRots = np.array([k * np.pi / 2
for k
in range(4)])
405 yaw = det.getOrientation().getYaw()
406 nb90Rots = np.argmin(np.abs(possibleRots - float(yaw)))
409 warpedStars, warpTransforms, xy0s = [], [], []
410 for star, cent
in zip(stamps, pixCenters):
413 bottomLeft = Point2D(star.image.getXY0())
414 newBottomLeft = pixToTan.applyForward(bottomLeft)
415 newBottomLeft.setX(newBottomLeft.getX() - bufferPix[0] / 2)
416 newBottomLeft.setY(newBottomLeft.getY() - bufferPix[1] / 2)
418 newBottomLeft = Point2I(newBottomLeft)
420 destImage.setXY0(newBottomLeft)
421 xy0s.append(newBottomLeft)
424 newCenter = pixToTan.applyForward(cent)
426 self.
modelCenter[0] + newBottomLeft[0] - newCenter[0],
427 self.
modelCenter[1] + newBottomLeft[1] - newCenter[1],
430 shiftTransform = makeTransform(affineShift)
433 starWarper = pixToTan.then(shiftTransform)
436 goodPix = warpImage(destImage, star.getMaskedImage(), starWarper, warpCont)
438 self.log.debug(
"Warping of a star failed: no good pixel in output")
441 destImage.setXY0(0, 0)
445 destImage = rotateImageBy90(destImage, nb90Rots)
446 warpedStars.append(destImage.clone())
447 warpTransforms.append(starWarper)
448 return Struct(warpedStars=warpedStars, warpTransforms=warpTransforms, xy0s=xy0s, nb90Rots=nb90Rots)
451 def run(self, inputExposure, refObjLoader=None, dataId=None, skyCorr=None):
452 """Identify bright stars within an exposure using a reference catalog,
453 extract stamps around each, then preprocess them. The preprocessing
454 steps are: shifting, warping and potentially rotating them to the same
455 pixel grid; computing their annular flux
and normalizing them.
459 inputExposure : `~lsst.afw.image.ExposureF`
460 The image
from which bright star stamps should be extracted.
461 refObjLoader : `~lsst.meas.algorithms.ReferenceObjectLoader`, optional
462 Loader to find objects within a reference catalog.
463 dataId : `dict`
or `~lsst.daf.butler.DataCoordinate`
464 The dataId of the exposure (
and detector) bright stars should be
466 skyCorr : `~lsst.afw.math.backgroundList.BackgroundList`, optional
467 Full focal plane sky correction obtained by `SkyCorrectionTask`.
471 result : `~lsst.pipe.base.Struct`
472 Results
as a struct
with attributes:
477 if self.config.doApplySkyCorr:
479 "Applying sky correction to exposure %s (exposure will be modified in-place).", dataId
482 self.log.info(
"Extracting bright stars from exposure %s", dataId)
484 extractedStamps = self.
extractStamps(inputExposure, refObjLoader=refObjLoader)
485 if not extractedStamps.starIms:
486 self.log.info(
"No suitable bright star found.")
490 "Applying warp and/or shift to %i star stamps from exposure %s.",
491 len(extractedStamps.starIms),
494 warpOutputs = self.
warpStamps(extractedStamps.starIms, extractedStamps.pixCenters)
495 warpedStars = warpOutputs.warpedStars
496 xy0s = warpOutputs.xy0s
500 archive_element=transform,
502 gaiaGMag=extractedStamps.GMags[j],
503 gaiaId=extractedStamps.gaiaIds[j],
505 for j, (warp, transform)
in enumerate(zip(warpedStars, warpOutputs.warpTransforms))
509 "Computing annular flux and normalizing %i bright stars from exposure %s.",
515 statsControl.setNumSigmaClip(self.config.numSigmaClip)
516 statsControl.setNumIter(self.config.numIter)
517 innerRadius, outerRadius = self.config.annularFluxRadii
518 statsFlag = stringToStatisticsProperty(self.config.annularFluxStatistic)
519 brightStarStamps = BrightStarStamps.initAndNormalize(
521 innerRadius=innerRadius,
522 outerRadius=outerRadius,
523 nb90Rots=warpOutputs.nb90Rots,
526 statsControl=statsControl,
528 badMaskPlanes=self.config.badMaskPlanes,
529 discardNanFluxObjects=(self.config.discardNanFluxStars),
531 return Struct(brightStarStamps=brightStarStamps)
534 inputs = butlerQC.get(inputRefs)
535 inputs[
"dataId"] =
str(butlerQC.quantum.dataId)
536 refObjLoader = ReferenceObjectLoader(
537 dataIds=[ref.datasetRef.dataId
for ref
in inputRefs.refCat],
538 refCats=inputs.pop(
"refCat"),
539 name=self.config.connections.refCat,
540 config=self.config.refObjLoader,
542 output = self.
run(**inputs, refObjLoader=refObjLoader)
544 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)