26from astropy.stats
import gaussian_sigma_to_fwhm
41from lsst.images
import (
43 DifferenceImageTemplateInfo,
44 get_legacy_optional_mask_planes,
45 get_legacy_template_mask_planes,
47from lsst.skymap
import BaseSkyMap
50from lsst.meas.algorithms
import CoaddPsf, CoaddPsfConfig, SubtractBackgroundTask, ScaleVarianceTask
51from lsst.utils.timer
import timeMethod
57 "GetDcrTemplateConfig",
61"""Width, in N sigma, of the Gaussian approximation to the CoaddPsf."""
63_UNKNOWN_INSTRUMENT =
"UNKNOWN"
64"""Instrument recorded for a coadd or template made from observations of an
71 """Compute the phase-averaged sum of squared values of a warping kernel.
73 Warping with a kernel normalized to unit sum multiplies the variance by
74 this sum, which is less than one. The power the warp took off the
75 diagonal became covariance between neighboring pixels, which a variance
76 plane cannot represent, so dividing by it restores the noise power: the
77 variance of a flux measured over a region and not a single pixel.
82 Name of the warping kernel, as `lsst.afw.math.Warper` takes it.
83 nPhase : `int`, optional
84 Number of sub-pixel phases to average over.
89 Sum of the squared 2-D kernel values, averaged over sub-pixel
90 phase. Converges with large ``nPhase`` to 0.798 for ``lanczos3``,
91 4/9 for ``bilinear``, and 1 for no interpolation.
95 The kernels are separable and the phases of the two axes independent,
96 so this is the square of the 1-D average. The warper normalizes the
97 kernel to unit sum at each phase, which the analytic Lanczos integral
98 does not include, so the average is computed from the kernel itself.
102 kernel = afwMath.Warper(kernelName, cacheSize=0).getWarpingKernel()
103 image = afwImage.ImageD(kernel.getDimensions())
105 for phase
in (np.arange(nPhase) + 0.5)/nPhase:
106 kernel.setKernelParameters((float(phase), 0.0))
107 kernel.computeImage(image, doNormalize=
True)
108 total += float((image.array.sum(axis=0)**2).sum())
109 return (total/nPhase)**2
113 pipeBase.PipelineTaskConnections,
114 dimensions=(
"instrument",
"visit",
"detector"),
115 defaultTemplates={
"coaddName":
"goodSeeing",
"warpTypeSuffix":
"",
"fakesType":
""},
117 bbox = pipeBase.connectionTypes.Input(
118 doc=
"Bounding box of exposure to determine the geometry of the output template.",
119 name=
"{fakesType}calexp.bbox",
120 storageClass=
"Box2I",
121 dimensions=(
"instrument",
"visit",
"detector"),
122 deprecated=
"Replaced by the `detector` connection. Will be removed after v31.",
124 detector = pipeBase.connectionTypes.Input(
125 doc=
"Detector of the exposure that we will construct the template for."
126 " Its bounding box sets the geometry of the output template.",
127 name=
"{fakesType}calexp.detector",
128 storageClass=
"Detector",
129 dimensions=(
"instrument",
"visit",
"detector"),
131 obs_info = pipeBase.connectionTypes.Input(
132 doc=
"Observation metadata of the exposure the template is built for."
133 " Only read with output_image_type='future', which needs this to set"
134 " the obs_info component of a lsst.images.VisitImage.",
135 name=
"{fakesType}calexp.obs_info",
136 storageClass=
"ObservationInfo",
137 dimensions=(
"instrument",
"visit",
"detector"),
139 wcs = pipeBase.connectionTypes.Input(
140 doc=
"WCS of the exposure that we will construct the template for.",
141 name=
"{fakesType}calexp.wcs",
143 dimensions=(
"instrument",
"visit",
"detector"),
145 skyMap = pipeBase.connectionTypes.Input(
146 doc=
"Geometry of the tracts and patches that the coadds are defined on.",
147 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
148 dimensions=(
"skymap",),
149 storageClass=
"SkyMap",
151 coaddExposures = pipeBase.connectionTypes.Input(
152 doc=
"Coadds that may overlap the desired region, as possible inputs to the template."
153 " Will be restricted to those that directly overlap the projected bounding box.",
154 dimensions=(
"tract",
"patch",
"skymap",
"band"),
155 storageClass=
"ExposureF",
156 name=
"{fakesType}{coaddName}Coadd{warpTypeSuffix}",
159 deferGraphConstraint=
True,
162 template = pipeBase.connectionTypes.Output(
163 doc=
"Warped template, pixel matched to the bounding box and WCS.",
164 dimensions=(
"instrument",
"visit",
"detector"),
165 storageClass=
"ExposureF",
166 name=
"{fakesType}{coaddName}Diff_templateExp{warpTypeSuffix}",
169 def __init__(self, *, config=None):
170 super().__init__(config=config)
173 if config.requireCoaddAtGraphBuild:
174 self.coaddExposures = dataclasses.replace(
176 deferGraphConstraint=
False,
178 if config.output_image_type ==
"future":
179 self.template = dataclasses.replace(self.template, storageClass=
"DifferenceImage")
184class GetTemplateConfig(
185 pipeBase.PipelineTaskConfig, pipelineConnections=GetTemplateConnections
187 templateBorderSize = pexConfig.Field(
190 doc=
"Number of pixels to grow the requested template image to account for warping",
192 warp = pexConfig.ConfigField(
193 dtype=afwMath.Warper.ConfigClass,
194 doc=
"warper configuration",
196 doCorrectVarianceWarpCorrelation = pexConfig.Field(
199 doc=
"Correct the template variance for the correlation that warping"
200 " introduced between neighboring pixels? This covers both warps: the"
201 " one that built the coadds, named by coaddWarpKernel, and the one"
202 " this task performs, named by warp.warpingKernelName. It is a single"
203 " scalar factor, fixed entirely by those two kernels and independent"
204 " of the pixel scales involved.",
206 coaddWarpKernel = pexConfig.ChoiceField(
210 "bilinear":
"bilinear interpolation",
211 "lanczos3":
"Lanczos kernel of order 3",
212 "lanczos4":
"Lanczos kernel of order 4",
213 "lanczos5":
"Lanczos kernel of order 5",
215 doc=
"Warping kernel that was used to build the input coadds. This"
216 " cannot be recovered from the coadds themselves, and is used only if"
217 " doCorrectVarianceWarpCorrelation is True.",
219 coaddPsf = pexConfig.ConfigField(
220 doc=
"Configuration for CoaddPsf",
221 dtype=CoaddPsfConfig,
223 fwhmExposureGrid = pexConfig.Field(
224 doc=
"Grid size to compute the average PSF FWHM in an exposure (pixels)",
228 fwhmExposureBuffer = pexConfig.Field(
229 doc=
"Fractional buffer margin to be left out of all sides of the image during construction"
230 " of grid to compute average PSF FWHM in an exposure",
234 varianceBackground = pexConfig.ConfigurableField(
235 target=SubtractBackgroundTask,
236 doc=
"Task to estimate the background variance.",
238 highVarianceThreshold = pexConfig.RangeField(
242 doc=
"Set the HIGH_VARIANCE mask plane for regions with variance"
243 " greater than the median by this factor.",
245 highVarianceMaskFraction = pexConfig.Field(
248 doc=
"Minimum fraction of unmasked pixels needed to set the"
249 " HIGH_VARIANCE mask plane.",
251 doScaleVariance = pexConfig.Field(
254 doc=
"Scale the variance of the template image to match its per-pixel"
255 " scatter? Warping correlates the noise, so when combined with"
256 " doCorrectVarianceWarpCorrelation this overcounts the correction.",
258 scaleVariance = pexConfig.ConfigurableField(
259 target=ScaleVarianceTask,
260 doc=
"Subtask to rescale the variance of the template to the statistically expected level."
262 requireCoaddAtGraphBuild = pexConfig.Field(
265 doc=
"If True, include the coadd dataset existence in the"
266 " initial butler query during QuantumGraph generation.",
268 output_image_type = pexConfig.ChoiceField[str](
269 "Which image type to use for the output template.",
271 "legacy":
"Write as a lsst.afw.image.ExposureF.",
272 "future":
"Write as a lsst.images.DifferenceImage.",
277 raiseOnUndefinedMaskMap = pexConfig.Field(
280 doc=
"Raise if the template has pixels set in a mask plane that"
281 " `lsst.images` does not map? If False, log a warning and drop that"
282 " plane from the output instead."
283 " Only used with output_image_type='future'.",
289 self.warp.cacheSize = 100000
290 self.coaddPsf.cacheSize = self.warp.cacheSize
293 self.warp.interpLength = 100
294 self.warp.warpingKernelName =
"lanczos3"
295 self.coaddPsf.warpingKernelName = self.warp.warpingKernelName
298 self.varianceBackground.algorithm =
"LINEAR"
299 self.varianceBackground.binSize = 32
300 self.varianceBackground.useApprox =
False
301 self.varianceBackground.statisticsProperty =
"MEDIAN"
302 self.varianceBackground.doFilterSuperPixels =
True
303 self.varianceBackground.ignoredPixelMask = [
"BAD",
312 ConfigClass = GetTemplateConfig
313 _DefaultName =
"getTemplate"
317 if self.config.doScaleVariance:
318 self.makeSubtask(
"scaleVariance")
319 self.
warper = afwMath.Warper.fromConfig(self.config.warp)
320 self.
schema = afwTable.ExposureTable.makeMinimalSchema()
322 "tract", type=np.int32, doc=
"Which tract this exposure came from."
327 doc=
"Which patch in the tract this exposure came from.",
332 doc=
"Weight for each exposure, used to make the CoaddPsf; should always be 1.",
338 doc=
"Instrument whose observations were coadded to make the template, or UNKNOWN.",
340 self.makeSubtask(
"varianceBackground")
343 inputs = butlerQC.get(inputRefs)
344 detector = inputs.pop(
"detector")
345 bbox = detector.getBBox()
346 wcs = inputs.pop(
"wcs")
347 obsInfo = inputs.pop(
"obs_info",
None)
348 coaddExposures = inputs.pop(
"coaddExposures")
349 skymap = inputs.pop(
"skyMap")
352 assert not inputs,
"runQuantum got more inputs than expected"
354 results = self.
getExposures(coaddExposures, bbox, skymap, wcs)
355 physical_filter = butlerQC.quantum.dataId[
"physical_filter"]
357 coaddExposureHandles=results.coaddExposures,
360 dataIds=results.dataIds,
361 physical_filter=physical_filter,
362 visit=outputRefs.template.dataId[
"visit"],
363 instrument=butlerQC.quantum.dataId[
"instrument"],
365 if self.config.output_image_type ==
"future":
366 dataId = butlerQC.quantum.dataId
367 exposureRecord = record_from_obs_info(obsInfo, dataId[
"instrument"], dataId[
"visit"],
370 inputRefs.coaddExposures,
372 exposureRecord=exposureRecord)
373 butlerQC.put(outputs, outputRefs)
376 """Convert a template image to DifferenceImage format.
378 This replaces ``template`` with an `lsst.images.DifferenceImage`
379 instance and records the coadds contributed to it, along with the
380 instrument they were made from. The Gaussian PSF set by `run` is
381 converted to an `lsst.images.psfs.GaussianPointSpreadFunction`.
385 template : `lsst.afw.image.Exposure`
386 Template to convert. Its detector is replaced with ``detector``
387 and its mask is modified in place.
388 coadd_refs : `list` [`lsst.daf.butler.DatasetRef`]
389 References to the coadds that may have gone into the template.
390 May be a superset of the coadds that were used; supplies the
391 dataset id and RUN collection recorded for each coadd that was.
392 detector : `lsst.afw.cameraGeom.Detector`
393 Detector of the science image that the template was built for.
394 exposureRecord : `lsst.daf.butler.DimensionRecord`
395 The ``exposure`` record of the observation the template was built
396 for, which supplies the observation metadata recorded on the
401 convertedTemplate : `lsst.images.DifferenceImage`
407 Raised if the template has pixels set in a mask plane that
408 `lsst.images.get_legacy_template_mask_planes` does not map and
409 ``config.raiseOnUndefinedMaskMap`` is True, or if ``coadd_refs``
410 spans more than one skymap.
414 catalog = template.getInfo().getCoaddInputs().ccds
415 coaddPsf = self.
_makePsf(template, catalog, template.getWcs())
417 instrument = catalog[0][
"instrument"]
418 template.setDetector(detector)
419 skymaps = {ref.dataId[
"skymap"]
for ref
in coadd_refs}
420 if len(skymaps) != 1:
421 raise RuntimeError(
"Expected the template inputs to come from one skymap;"
422 f
" got {sorted(skymaps)}.")
423 butler_info = {(ref.dataId[
"tract"], ref.dataId[
"patch"]): (ref.id, ref.run)
424 for ref
in coadd_refs}
425 planeMap = get_legacy_template_mask_planes()
426 if not self.config.raiseOnUndefinedMaskMap:
428 convertedTemplate = DifferenceImage.from_legacy(
430 exposure_record=exposureRecord,
431 unit=astropy.units.nJy,
435 convertedTemplate.templates = DifferenceImageTemplateInfo.from_legacy_psf(
436 convertedTemplate.sky_projection.pixel_frame,
441 instrument=instrument,
443 return convertedTemplate
446 """Clear the pixels of every mask plane that conversion would reject.
450 mask : `lsst.afw.image.Mask`
451 Template mask to modify in place.
452 planeMap : `dict` [`str`, `lsst.images.MaskPlane`]
453 Plane map the template will be converted with. The optional
454 planes of `lsst.images.get_legacy_optional_mask_planes` are kept
455 as well, since conversion adds them when they have pixels set.
457 keep = planeMap.keys() | get_legacy_optional_mask_planes().keys()
458 for name
in mask.getMaskPlaneDict():
461 bitmask = mask.getPlaneBitMask(name)
462 nSet = np.count_nonzero(mask.array & bitmask)
464 self.log.warning(
"Dropping mask plane %s from the template: %d pixels are set, but"
465 " the template plane map does not include it.", name, nSet)
466 mask.array &= ~bitmask
469 """Return a data structure containing the coadds that overlap the
470 specified bbox projected onto the sky, and a corresponding data
471 structure of their dataIds.
472 These are the appropriate inputs to this task's `run` method.
474 The spatial index in the butler registry has generous padding and often
475 supplies patches near, but not directly overlapping the desired region.
476 This method filters the inputs so that `run` does not have to read in
477 all possibly-matching coadd exposures.
481 coaddExposureHandles : `iterable` \
482 [`lsst.daf.butler.DeferredDatasetHandle` of \
483 `lsst.afw.image.Exposure`]
484 Dataset handles to exposures that might overlap the desired
486 bbox : `lsst.geom.Box2I`
487 Template bounding box of the pixel geometry onto which the
488 coaddExposures will be resampled.
489 skymap : `lsst.skymap.SkyMap`
490 Geometry of the tracts and patches the coadds are defined on.
491 wcs : `lsst.afw.geom.SkyWcs`
492 Template WCS onto which the coadds will be resampled.
496 result : `lsst.pipe.base.Struct`
497 A struct with attributes:
500 Dict of coadd exposures that overlap the projected bbox,
502 (`dict` [`int`, `list` [`lsst.daf.butler.DeferredDatasetHandle` of
503 `lsst.afw.image.Exposure`] ]).
505 Dict of data IDs of the coadd exposures that overlap the
506 projected bbox, indexed on tract id
507 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
512 Raised if no patches overlap the input detector bbox, or the input
516 raise pipeBase.NoWorkFound(
517 "WCS is None; cannot find overlapping exposures."
522 detectorCorners = wcs.pixelToSky(detectorPolygon.getCorners())
524 coaddExposures = collections.defaultdict(list)
525 dataIds = collections.defaultdict(list)
527 for coaddRef
in coaddExposureHandles:
528 dataId = coaddRef.dataId
529 patchWcs = skymap[dataId[
"tract"]].getWcs()
530 patchBBox = skymap[dataId[
"tract"]][dataId[
"patch"]].getOuterBBox()
531 patchPolygon = afwGeom.Polygon(
geom.Box2D(patchBBox))
536 detectorInPatchCoordinates = afwGeom.Polygon(patchWcs.skyToPixel(detectorCorners))
537 if patchPolygon.intersection(detectorInPatchCoordinates):
538 overlappingArea += patchPolygon.intersectionSingle(
539 detectorInPatchCoordinates
542 "Using template input tract=%s, patch=%s",
546 coaddExposures[dataId[
"tract"]].append(coaddRef)
547 dataIds[dataId[
"tract"]].append(dataId)
549 if not overlappingArea:
550 raise pipeBase.NoWorkFound(
"No patches overlap detector")
552 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
555 def run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None, instrument=None):
556 """Warp coadds from multiple tracts and patches to form a template to
557 subtract from a science image.
559 Tract and patch overlap regions are combined by a variance-weighted
560 average, and the variance planes are combined with the same weights,
561 not added in quadrature; the overlap regions are not statistically
562 independent, because they're derived from the same original data.
563 The PSF of the template is a Gaussian with the same width as the
564 CoaddPsf evaluated at the center of the detector.
566 The template is built from coadds of one instrument: the science
567 image's ``instrument`` if any coadd was made from it, and otherwise the
568 one that most of the coadds were made from. Coadds made from another
569 instrument are dropped, and so are coadds that record no instrument
570 unless none of the coadds records one.
574 coaddExposureHandles : `dict` [`int`, `list` of \
575 [`lsst.daf.butler.DeferredDatasetHandle` of \
576 `lsst.afw.image.Exposure`]]
577 Coadds to be mosaicked, indexed on tract id.
578 bbox : `lsst.geom.Box2I`
579 Input Bounding box of the detector geometry onto which to
580 resample the ``coaddExposureHandles``. The output template bbox
581 will be grown by ``templateBorderSize``.
582 wcs : `lsst.afw.geom.SkyWcs`
583 Template WCS onto which to resample the ``coaddExposureHandles``.
584 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
585 Record of the tract and patch of each coaddExposure, indexed on
587 physical_filter : `str`
588 Physical filter of the science image.
589 visit : `int`, optional
590 If supplied, over-write the visit ID in the template's visitInfo
591 so that downstream source injection tasks can link the template and
592 science image for the visit.
593 instrument : `str`, optional
594 Instrument of the science image. Coadds made from it are used in
595 preference to coadds from any other instrument.
599 result : `lsst.pipe.base.Struct`
600 A struct with attributes:
603 A template coadd exposure assembled out of patches, with
604 pixels in nJy. Its PSF is a Gaussian approximation to the
605 CoaddPsf of its inputs (`lsst.afw.image.ExposureF`).
610 If no coadds are found with sufficient un-masked pixels.
612 If the template is not known to be from the science image's
613 instrument and no coadd records the plate scale of its input
614 images, so the template variance cannot be corrected.
616 band, photoCalib, templateInstrument = self.
_checkInputs(dataIds, coaddExposureHandles, instrument)
619 bbox.grow(self.config.templateBorderSize)
623 plateScaleFactor = 1.0
if instrument == templateInstrument
else None
625 for tract
in coaddExposureHandles:
627 coaddExposureHandles[tract], dataIds[tract], templateInstrument
629 if len(catalog) == 0:
636 if plateScaleFactor
is None:
638 warpedBox = computeWarpedBBox(catalog[0].wcs, bbox, wcs)
641 unwarped, count, included = self.
_merge(
642 maskedImages, warpedBox, catalog[0].wcs
648 "No valid pixels from coadd patches in tract %s; not including in output.",
652 warpedBox.clip(totalBox)
653 potentialInput = self.
warper.warpExposure(
654 wcs, unwarped.subset(warpedBox), destBBox=bbox
659 potentialInput.mask.array
660 & potentialInput.mask.getPlaneBitMask(
"NO_DATA")
663 "No overlap from coadd patches in tract %s; not including in output.",
669 tempCatalog = afwTable.ExposureCatalog(self.
schema)
670 tempCatalog.reserve(len(included))
672 tempCatalog.append(catalog[i])
673 catalogs.append(tempCatalog)
674 warped[tract] = potentialInput.maskedImage
677 raise pipeBase.NoWorkFound(
"No patches found to overlap science exposure.")
680 template, count, _ = self.
_merge(warped, bbox, wcs)
682 raise pipeBase.NoWorkFound(
"No valid pixels in warped template.")
684 if self.config.doScaleVariance:
688 varianceFactor = self.scaleVariance.
run(template.maskedImage)
689 self.log.info(
"Template variance scaling factor: %.2f", varianceFactor)
690 self.metadata[
"scaleTemplateVarianceFactor"] = varianceFactor
695 catalog = afwTable.ExposureCatalog(self.
schema)
696 catalog.reserve(sum([len(c)
for c
in catalogs]))
702 if visit
is not None:
703 template.getInfo().setVisitInfo(
VisitInfo(id=visit))
706 if photoCalib
is not None and photoCalib != identity:
707 template.maskedImage = photoCalib.calibrateImage(template.maskedImage)
708 photoCalib = identity
709 template.setPhotoCalib(photoCalib)
710 template.setPsf(self.
_makePsf(template, catalog, wcs))
715 coaddInputs.ccds.extend(catalog, deep=
True)
716 template.getInfo().setCoaddInputs(coaddInputs)
717 return pipeBase.Struct(template=template)
720 """Correct the template variance plane for the effects of warping.
722 Warping introduces a covariance between neighboring pixels, while the
723 input variance plane captures only the diagonal term. Compute a
724 multiplicative correction factor to apply to the final variance so that
725 the detection significance on the convolved difference image is correct
728 Separately, also apply a scalar multiplicative correction factor to the
729 template variance if the plate scale of the coadd's constituent images
730 is different than the science image the template is being constructed
731 for. This should only be necessary if the coadd images were from a
732 different instrument than the science image, since the scale change
733 otherwise cancels between the two warps. If the plate scale of the
734 science instrument is smaller than the plate scale of the coadd
735 instrument, then the template pixels will be correlated and the true
736 variance will be higher than the image pixel noise level would suggest.
740 template : `lsst.afw.image.Exposure`
741 Assembled template; its variance plane is modified in place.
742 plateScaleFactor : `float` or `None`
743 Correction for the change in pixel area, from `_plateScaleFactor`.
748 Raised if ``plateScaleFactor`` is `None`.
750 if plateScaleFactor
is None:
752 "Could not determine the plate scale of the images the template coadds were made from,"
753 " so the template variance cannot be corrected for the change in pixel area."
755 self.metadata[
"variancePlateScaleFactor"] = plateScaleFactor
756 correlationFactor = 1.0
757 if self.config.doCorrectVarianceWarpCorrelation:
762 self.metadata[
"varianceWarpCorrelationFactor"] = correlationFactor
763 scale = correlationFactor*plateScaleFactor
766 template.variance.array *= scale
767 self.metadata[
"templateVarianceCorrectionFactor"] = scale
769 "Variance correction factor due to plate scale changes: %.4f",
773 "Variance correction factor due to warping correlations: %.4f",
777 "Overall template variance correction factor: %.4f",
782 """Compute the variance correction for the total change in pixel area
783 between the coadd's constituent images and the science image.
787 coaddInputs : `lsst.afw.image.CoaddInputs` or `None`
788 Input records of one of the coadd patches.
789 wcs : `lsst.afw.geom.SkyWcs`
790 WCS of the science image the template is being built for.
791 bbox : `lsst.geom.Box2I`
792 Bounding box of the template, used only to choose where to
793 evaluate the science image pixel scale.
797 factor : `float` or `None`
798 The correction factor equivalent to the ratio of the area of a pixel
799 from the coadd's instrument to the science instrument, or `None` if
800 this patch carries no input record from which it could be computed.
802 if coaddInputs
is None:
804 scienceScale = wcs.getPixelScale(
geom.Box2D(bbox).getCenter()).asArcseconds()
806 for record
in coaddInputs.ccds:
809 originalWcs = record.getWcs()
810 if originalWcs
is None:
812 center =
geom.Box2D(record.getBBox()).getCenter()
813 pviScale = originalWcs.getPixelScale(center).asArcseconds()
815 factor = (pviScale/scienceScale)**2
816 if np.isfinite(factor)
and factor > 0:
821 """Set a mask plane for regions with unusually high variance.
825 template : `lsst.afw.image.Exposure`
826 The warped template exposure, which will be modified in place.
828 highVarianceMaskPlaneBit = template.mask.addMaskPlane(
"HIGH_VARIANCE")
829 ignoredPixelBits = template.mask.getPlaneBitMask(self.varianceBackground.config.ignoredPixelMask)
830 goodMask = (template.mask.array & ignoredPixelBits) == 0
831 goodFraction = np.count_nonzero(goodMask)/template.mask.array.size
832 if goodFraction < self.config.highVarianceMaskFraction:
833 self.log.info(
"Not setting HIGH_VARIANCE mask plane, only %2.1f%% of"
834 " pixels were unmasked for background estimation, but"
835 " %2.1f%% are required", 100*goodFraction, 100*self.config.highVarianceMaskFraction)
837 varianceExposure = template.clone()
838 varianceExposure.image.array = varianceExposure.variance.array
839 varianceBackground = self.varianceBackground.
run(varianceExposure).background.getImage().array
840 threshold = self.config.highVarianceThreshold*np.nanmedian(varianceBackground)
841 highVariancePix = varianceBackground > threshold
842 template.mask.array[highVariancePix] |= 2**highVarianceMaskPlaneBit
846 """Check that the all the dataIds are from the same band and that
847 the exposures all have the same photometric calibration, and choose
848 the instrument of the template.
852 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
853 Record of the tract and patch of each coaddExposure.
854 coaddExposures : `dict` [`int`, `list` of \
855 [`lsst.daf.butler.DeferredDatasetHandle` of \
856 `lsst.afw.image.Exposure` or
857 `lsst.afw.image.Exposure`]]
858 Coadds to be mosaicked.
859 scienceInstrument : `str`, optional
860 Instrument of the science image.
865 Filter band of all the input exposures.
866 photoCalib : `lsst.afw.image.PhotoCalib`
867 Photometric calibration of all of the input exposures.
869 ``scienceInstrument`` if any coadd was made from it, otherwise the
870 instrument that the most coadds were made from, or ``"UNKNOWN"`` if
871 no coadd records one. A tie goes to the instrument whose name sorts
877 Raised if the bands or calibrations of the input exposures are not
880 bands = set(dataId[
"band"]
for tract
in dataIds
for dataId
in dataIds[tract])
882 raise RuntimeError(f
"GetTemplateTask called with multiple bands: {bands}")
885 instrumentCounts = collections.Counter()
886 for exposures
in coaddExposures.values():
887 for exposure
in exposures:
890 photoCalibs.append(exposure.get(component=
"photoCalib"))
891 instrumentCounts[GetTemplateTask._getInstrument(exposure.get(component=
"coaddInputs"))] += 1
892 if not all([photoCalibs[0] == x
for x
in photoCalibs]):
893 msg = f
"GetTemplateTask called with exposures with different photoCalibs: {photoCalibs}"
894 raise RuntimeError(msg)
895 photoCalib = photoCalibs[0]
897 del instrumentCounts[_UNKNOWN_INSTRUMENT]
898 if scienceInstrument
in instrumentCounts:
899 return band, photoCalib, scienceInstrument
900 if not instrumentCounts:
901 return band, photoCalib, _UNKNOWN_INSTRUMENT
902 instrument = max(sorted(instrumentCounts), key=instrumentCounts.get)
903 return band, photoCalib, instrument
907 """Return the instrument whose observations were coadded to make a
912 coaddInputs : `lsst.afw.image.CoaddInputs` or `None`
913 Input records of the coadd.
918 Instrument label of the coadd, or ``"UNKNOWN"`` if it records none.
922 Every input of a coadd comes from the same instrument, so the first
923 input record stands for all of them.
925 if coaddInputs
is None or len(coaddInputs.ccds) == 0:
926 return _UNKNOWN_INSTRUMENT
927 visitInfo = coaddInputs.ccds[0].getVisitInfo()
928 label = visitInfo.getInstrumentLabel()
if visitInfo
is not None else ""
929 return label
or _UNKNOWN_INSTRUMENT
932 """Make an exposure catalog for one tract.
936 exposureRefs : `list` of [`lsst.daf.butler.DeferredDatasetHandle` of \
937 `lsst.afw.image.Exposure`]
938 Exposures to include in the catalog.
939 dataIds : `list` [`lsst.daf.butler.DataCoordinate`]
940 Data ids of each of the included exposures; must have "tract" and
942 instrument : `str`, optional
943 Instrument of the template, recorded in the catalog. Exposures
944 made from another instrument are dropped, as are exposures that
945 record no instrument unless ``instrument`` is ``"UNKNOWN"``.
949 images : `dict` [`lsst.afw.image.MaskedImage`]
950 MaskedImages of each of the input exposures, for warping.
951 catalog : `lsst.afw.table.ExposureCatalog`
952 Catalog of metadata for each exposure
953 totalBox : `lsst.geom.Box2I`
954 The union of the bounding boxes of all the input exposures.
955 coaddInputs : `lsst.afw.image.CoaddInputs` or `None`
956 Input records of the first exposure kept, or `None` if none is
959 catalog = afwTable.ExposureCatalog(self.
schema)
960 catalog.reserve(len(exposureRefs))
961 exposures = (exposureRef.get()
for exposureRef
in exposureRefs)
964 firstCoaddInputs =
None
966 for coadd, dataId
in zip(exposures, dataIds):
967 coaddInputs = coadd.getInfo().getCoaddInputs()
969 if coaddInstrument != instrument:
971 "Dropping template coadd %s: it was made from %s, but the template is made from %s.",
978 firstCoaddInputs = coaddInputs
979 images[dataId] = coadd.maskedImage
980 bbox = coadd.getBBox()
981 totalBox = totalBox.expandedTo(bbox)
982 record = catalog.addNew()
983 record.setPsf(coadd.psf)
984 record.setWcs(coadd.wcs)
985 record.setPhotoCalib(coadd.photoCalib)
987 record.setValidPolygon(afwGeom.Polygon(
geom.Box2D(bbox).getCorners()))
988 record.set(
"tract", dataId[
"tract"])
989 record.set(
"patch", dataId[
"patch"])
990 record.set(
"instrument", instrument)
993 record.set(
"weight", 1)
995 return images, catalog, totalBox, firstCoaddInputs
997 def _merge(self, maskedImages, bbox, wcs):
998 """Merge the images that came from one tract into one larger image,
999 ignoring NaN pixels and non-finite variance pixels from individual
1004 maskedImages : `dict` [`lsst.afw.image.MaskedImage` or
1005 `lsst.afw.image.Exposure`]
1006 Images to be merged into one larger bounding box.
1007 bbox : `lsst.geom.Box2I`
1008 Bounding box defining the image to merge into.
1009 wcs : `lsst.afw.geom.SkyWcs`
1010 WCS of all of the input images to set on the output image.
1014 merged : `lsst.afw.image.MaskedImage`
1015 Merged image with all of the inputs at their respective bbox
1018 Count of the number of good pixels (those with positive weights)
1019 in the merged image.
1020 included : `list` [`int`]
1021 List of indexes of patches that were included in the merged
1022 result, to be used to trim the exposure catalog.
1024 merged = afwImage.ExposureF(bbox, wcs)
1025 weights = afwImage.ImageF(bbox)
1027 for i, (dataId, maskedImage)
in enumerate(maskedImages.items()):
1029 clippedBox =
geom.Box2I(maskedImage.getBBox())
1030 clippedBox.clip(bbox)
1031 if clippedBox.area == 0:
1032 self.log.debug(
"%s does not overlap template region.", dataId)
1034 maskedImage = maskedImage.subset(clippedBox)
1036 good = (maskedImage.variance.array > 0) & (
1037 np.isfinite(maskedImage.variance.array)
1039 weight = maskedImage.variance.array[good] ** (-0.5)
1040 bad = np.isnan(maskedImage.image.array) | ~good
1043 maskedImage.image.array[bad] = 0.0
1044 maskedImage.variance.array[bad] = 0.0
1046 maskedImage.mask.array[bad] = 0
1050 maskedImage.image.array[good] *= weight
1051 maskedImage.variance.array[good] *= weight
1052 weights[clippedBox].array[good] += weight
1055 merged.maskedImage[clippedBox] += maskedImage
1058 good = weights.array > 0
1063 weights = weights.array[good]
1064 merged.image.array[good] /= weights
1065 merged.variance.array[good] /= weights
1067 merged.mask.array[~good] |= merged.mask.getPlaneBitMask(
"NO_DATA")
1069 return merged, good.sum(), included
1072 """Return a PSF containing the PSF at each of the input regions.
1074 Note that although this includes all the exposures from the catalog,
1075 the PSF knows which part of the template the inputs came from, so when
1076 evaluated at a given position it will not include inputs that never
1077 went in to those pixels.
1081 template : `lsst.afw.image.Exposure`
1082 Generated template the PSF is for.
1083 catalog : `lsst.afw.table.ExposureCatalog`
1084 Catalog of exposures that went into the template that contains all
1086 wcs : `lsst.afw.geom.SkyWcs`
1087 WCS of the template, to warp the PSFs to.
1091 coaddPsf : `lsst.meas.algorithms.CoaddPsf`
1092 The meta-psf constructed from all of the input catalogs.
1096 boolmask = template.mask.array & template.mask.getPlaneBitMask(
"NO_DATA") == 0
1098 centerCoord = afwGeom.SpanSet.fromMask(maskx, 1).computeCentroid()
1100 ctrl = self.config.coaddPsf.makeControl()
1101 coaddPsf = CoaddPsf(
1102 catalog, wcs, centerCoord, ctrl.warpingKernelName, ctrl.cacheSize
1107 """Return a Gaussian PSF with the width of the template CoaddPsf.
1111 template : `lsst.afw.image.Exposure`
1112 Template with the CoaddPsf to approximate. The width is measured
1113 at the average position of the PSF, or averaged over a grid of
1114 positions if the PSF cannot be evaluated there.
1118 psf : `lsst.afw.detection.GaussianPsf`
1119 Gaussian with the width of the template PSF.
1124 Raised if the PSF cannot be evaluated at any grid position.
1129 fwhm = getPsfFwhm(template.psf)
1136 self.log.info(
"Unable to evaluate PSF at the average position. "
1137 "Evaluating PSF on a grid of points.")
1138 fwhm = evaluateMeanPsfFwhm(template,
1139 fwhmExposureBuffer=self.config.fwhmExposureBuffer,
1140 fwhmExposureGrid=self.config.fwhmExposureGrid)
1141 sigma = fwhm/gaussian_sigma_to_fwhm
1142 size = 2*int(np.ceil(_PSF_STAMP_SIGMAS*sigma)) + 1
1143 self.metadata[
"templatePsfSigma"] = float(sigma)
1144 self.log.info(
"Approximated the template PSF with a Gaussian of sigma %.3f pixels.", sigma)
1145 return afwDetection.GaussianPsf(size, size, sigma)
1149 GetTemplateConnections,
1150 dimensions=(
"instrument",
"visit",
"detector"),
1151 defaultTemplates={
"coaddName":
"dcr",
"warpTypeSuffix":
"",
"fakesType":
""},
1153 visitInfo = pipeBase.connectionTypes.Input(
1154 doc=
"VisitInfo of calexp used to determine observing conditions.",
1155 name=
"{fakesType}calexp.visitInfo",
1156 storageClass=
"VisitInfo",
1157 dimensions=(
"instrument",
"visit",
"detector"),
1159 dcrCoadds = pipeBase.connectionTypes.Input(
1160 doc=
"Input DCR template to match and subtract from the exposure",
1161 name=
"{fakesType}dcrCoadd{warpTypeSuffix}",
1162 storageClass=
"ExposureF",
1163 dimensions=(
"tract",
"patch",
"skymap",
"band",
"subfilter"),
1168 def __init__(self, *, config=None):
1169 super().__init__(config=config)
1170 self.inputs.remove(
"coaddExposures")
1173class GetDcrTemplateConfig(
1174 GetTemplateConfig, pipelineConnections=GetDcrTemplateConnections
1176 """Config for GetDcrTemplateTask.
1178 Only ``output_image_type="legacy"`` is supported: the task always writes
1179 an `lsst.afw.image.ExposureF`.
1182 numSubfilters = pexConfig.Field(
1183 doc=
"Number of subfilters in the DcrCoadd.",
1187 effectiveWavelength = pexConfig.Field(
1188 doc=
"Effective wavelength of the filter in nm.",
1192 bandwidth = pexConfig.Field(
1193 doc=
"Bandwidth of the physical filter.",
1199 if self.effectiveWavelength
is None or self.bandwidth
is None:
1201 "The effective wavelength and bandwidth of the physical filter "
1202 "must be set in the getTemplate config for DCR coadds. "
1203 "Required until transmission curves are used in DM-13668."
1208 ConfigClass = GetDcrTemplateConfig
1209 _DefaultName =
"getDcrTemplate"
1211 def runQuantum(self, butlerQC, inputRefs, outputRefs):
1212 inputs = butlerQC.get(inputRefs)
1213 detector = inputs.pop(
"detector")
1214 bbox = detector.getBBox()
1215 wcs = inputs.pop(
"wcs")
1216 dcrCoaddExposureHandles = inputs.pop(
"dcrCoadds")
1217 skymap = inputs.pop(
"skyMap")
1218 visitInfo = inputs.pop(
"visitInfo")
1221 inputs.pop(
"obs_info",
None)
1224 assert not inputs,
"runQuantum got more inputs than expected"
1226 results = self.getExposures(
1227 dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo
1229 physical_filter = butlerQC.quantum.dataId[
"physical_filter"]
1231 coaddExposureHandles=results.coaddExposures,
1234 dataIds=results.dataIds,
1235 physical_filter=physical_filter,
1236 visit=outputRefs.template.dataId[
"visit"],
1237 instrument=butlerQC.quantum.dataId[
"instrument"],
1239 butlerQC.put(outputs, outputRefs)
1241 def getExposures(self, dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo):
1242 """Return lists of coadds and their corresponding dataIds that overlap
1245 The spatial index in the registry has generous padding and often
1246 supplies patches near, but not directly overlapping the detector.
1247 Filters inputs so that we don't have to read in all input coadds.
1251 dcrCoaddExposureHandles : `list` \
1252 [`lsst.daf.butler.DeferredDatasetHandle` of \
1253 `lsst.afw.image.Exposure`]
1254 Data references to exposures that might overlap the detector.
1255 bbox : `lsst.geom.Box2I`
1256 Template Bounding box of the detector geometry onto which to
1257 resample the coaddExposures.
1258 skymap : `lsst.skymap.SkyMap`
1259 Input definition of geometry/bbox and projection/wcs for
1261 wcs : `lsst.afw.geom.SkyWcs`
1262 Template WCS onto which to resample the coaddExposures.
1263 visitInfo : `lsst.afw.image.VisitInfo`
1264 Metadata for the science image.
1268 result : `lsst.pipe.base.Struct`
1269 A struct with attibutes:
1272 Dict of coadd exposures that overlap the projected bbox,
1274 (`dict` [`int`, `list` [`lsst.afw.image.Exposure`] ]).
1276 Dict of data IDs of the coadd exposures that overlap the
1277 projected bbox, indexed on tract id
1278 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
1282 pipeBase.NoWorkFound
1283 Raised if no patches overlatp the input detector bbox.
1288 raise pipeBase.NoWorkFound(
"Exposure has no WCS; cannot create a template.")
1292 dataIds = collections.defaultdict(list)
1294 for coaddRef
in dcrCoaddExposureHandles:
1295 dataId = coaddRef.dataId
1296 subfilter = dataId[
"subfilter"]
1297 patchWcs = skymap[dataId[
"tract"]].getWcs()
1298 patchBBox = skymap[dataId[
"tract"]][dataId[
"patch"]].getOuterBBox()
1299 patchCorners = patchWcs.pixelToSky(
geom.Box2D(patchBBox).getCorners())
1300 patchPolygon = afwGeom.Polygon(wcs.skyToPixel(patchCorners))
1301 if patchPolygon.intersection(detectorPolygon):
1302 overlappingArea += patchPolygon.intersectionSingle(
1306 "Using template input tract=%s, patch=%s, subfilter=%s"
1307 % (dataId[
"tract"], dataId[
"patch"], dataId[
"subfilter"])
1309 if dataId[
"tract"]
in patchList:
1310 patchList[dataId[
"tract"]].append(dataId[
"patch"])
1312 patchList[dataId[
"tract"]] = [
1316 dataIds[dataId[
"tract"]].append(dataId)
1318 if not overlappingArea:
1319 raise pipeBase.NoWorkFound(
"No patches overlap detector")
1321 self.checkPatchList(patchList)
1323 coaddExposures = self.getDcrModel(patchList, dcrCoaddExposureHandles, visitInfo)
1324 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
1327 """Check that all of the DcrModel subfilters are present for each
1333 Dict of the patches containing valid data for each tract.
1338 If the number of exposures found for a patch does not match the
1339 number of subfilters.
1341 for tract
in patchList:
1342 for patch
in set(patchList[tract]):
1343 if patchList[tract].count(patch) != self.config.numSubfilters:
1345 "Invalid number of DcrModel subfilters found: %d vs %d expected",
1346 patchList[tract].count(patch),
1347 self.config.numSubfilters,
1351 """Build DCR-matched coadds from a list of exposure references.
1356 Dict of the patches containing valid data for each tract.
1357 coaddRefs : `list` [`lsst.daf.butler.DeferredDatasetHandle`]
1358 Data references to `~lsst.afw.image.Exposure` representing
1359 DcrModels that overlap the detector.
1360 visitInfo : `lsst.afw.image.VisitInfo`
1361 Metadata for the science image.
1365 coaddExposures : `list` [`lsst.afw.image.Exposure`]
1366 Coadd exposures that overlap the detector.
1368 coaddExposures = collections.defaultdict(list)
1369 for tract
in patchList:
1370 for patch
in set(patchList[tract]):
1373 for coaddRef
in coaddRefs
1377 dcrModel = DcrModel.fromQuantum(
1379 self.config.effectiveWavelength,
1380 self.config.bandwidth,
1381 self.config.numSubfilters,
1383 coaddExposures[tract].append(dcrModel.buildMatchedExposureHandle(visitInfo=visitInfo))
1384 return coaddExposures
1388 condition = (coaddRef.dataId[
"tract"] == tract) & (
1389 coaddRef.dataId[
"patch"] == patch
checkHighVariance(self, template)
_clearUnmappedMaskPlanes(self, mask, planeMap)
_correctVariance(self, template, plateScaleFactor)
_getInstrument(coaddInputs)
_makeGaussianPsf(self, template)
_plateScaleFactor(self, coaddInputs, wcs, bbox)
getExposures(self, coaddExposureHandles, bbox, skymap, wcs)
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None, instrument=None)
convert_outputs_to_future(self, template, coadd_refs, detector, exposureRecord)
_checkInputs(dataIds, coaddExposures, scienceInstrument=None)
_makePsf(self, template, catalog, wcs)
_makeExposureCatalog(self, exposureRefs, dataIds, instrument=_UNKNOWN_INSTRUMENT)
_merge(self, maskedImages, bbox, wcs)
runQuantum(self, butlerQC, inputRefs, outputRefs)
__init__(self, *args, **kwargs)
_warpKernelSumSq(kernelName, nPhase=100)
getDcrModel(self, patchList, coaddRefs, visitInfo)
checkPatchList(self, patchList)
_selectDataRef(coaddRef, tract, patch)