22from __future__
import annotations
24from typing
import TYPE_CHECKING, Iterable
33 CloughTocher2DInterpolateTask,
46 PipelineTaskConnections,
49from lsst.pipe.base.connectionTypes
import Input, Output
53from .coaddInputRecorder
import CoaddInputRecorderTask
61 "MakeDirectWarpConfig",
67 PipelineTaskConnections,
68 dimensions=(
"tract",
"patch",
"skymap",
"instrument",
"visit"),
74 """Connections for MakeWarpTask"""
77 doc=
"Input exposures to be interpolated and resampled onto a SkyMap "
79 name=
"{calexpType}calexp",
80 storageClass=
"ExposureF",
81 dimensions=(
"instrument",
"visit",
"detector"),
85 background_revert_list = Input(
86 doc=
"Background to be reverted (i.e., added back to the calexp). "
87 "This connection is used only if doRevertOldBackground=False.",
88 name=
"calexpBackground",
89 storageClass=
"Background",
90 dimensions=(
"instrument",
"visit",
"detector"),
93 background_apply_list = Input(
94 doc=
"Background to be applied (subtracted from the calexp). "
95 "This is used only if doApplyNewBackground=True.",
97 storageClass=
"Background",
98 dimensions=(
"instrument",
"visit",
"detector"),
101 visit_summary = Input(
102 doc=
"Input visit-summary catalog with updated calibration objects.",
103 name=
"finalVisitSummary",
104 storageClass=
"ExposureCatalog",
105 dimensions=(
"instrument",
"visit"),
108 doc=
"Input definition of geometry/bbox and projection/wcs for warps.",
109 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
110 storageClass=
"SkyMap",
111 dimensions=(
"skymap",),
115 doc=
"Output direct warped exposure produced by resampling calexps "
116 "onto the skyMap patch geometry.",
117 name=
"{coaddName}Coadd_directWarp",
118 storageClass=
"ExposureF",
119 dimensions=(
"tract",
"patch",
"skymap",
"instrument",
"visit"),
121 masked_fraction_warp = Output(
122 doc=
"Output masked fraction warped exposure.",
123 name=
"{coaddName}Coadd_directWarp_maskedFraction",
124 storageClass=
"ImageF",
125 dimensions=(
"tract",
"patch",
"skymap",
"instrument",
"visit"),
128 def __init__(self, *, config=None):
129 super().__init__(config=config)
133 if not config.doRevertOldBackground:
134 del self.background_revert_list
135 if not config.doApplyNewBackground:
136 del self.background_apply_list
140 for n
in range(config.numberOfNoiseRealizations):
142 doc=f
"Output direct warped noise exposure ({n})",
143 name=f
"{config.connections.coaddName}Coadd_directWarp_noise{n}",
145 storageClass=
"MaskedImageF",
146 dimensions=(
"tract",
"patch",
"skymap",
"instrument",
"visit"),
148 setattr(self, f
"noise_warp{n}", noise_warp)
151class MakeDirectWarpConfig(
153 pipelineConnections=MakeDirectWarpConnections,
155 """Configuration for the MakeDirectWarpTask.
157 The config fields are as similar as possible to the corresponding fields in
162 The config fields are in camelCase to match the fields in the earlier
163 version of the makeWarp task as closely as possible.
166 MAX_NUMBER_OF_NOISE_REALIZATIONS = 3
168 numberOfNoiseRealizations is defined as a RangeField to prevent from
169 making multiple output connections and blowing up the memory usage by
170 accident. An upper bound of 3 is based on the best guess of the maximum
171 number of noise realizations that will be used for metadetection.
174 numberOfNoiseRealizations = RangeField[int](
175 doc=
"Number of noise realizations to simulate and persist.",
178 max=MAX_NUMBER_OF_NOISE_REALIZATIONS,
181 seedOffset = Field[int](
182 doc=
"Offset to the seed used for the noise realization. This can be "
183 "used to create a different noise realization if the default ones "
184 "are catastrophic, or for testing sensitivity to the noise.",
187 useMedianVariance = Field[bool](
188 doc=
"Use the median of variance plane in the input calexp to generate "
189 "noise realizations? If False, per-pixel variance will be used.",
192 doRevertOldBackground = Field[bool](
193 doc=
"Revert the old backgrounds from the `background_revert_list` "
197 doApplyNewBackground = Field[bool](
198 doc=
"Apply the new backgrounds from the `background_apply_list` "
202 useVisitSummaryPsf = Field[bool](
203 doc=
"If True, use the PSF model and aperture corrections from the "
204 "'visit_summary' connection. If False, use the PSF model and "
205 "aperture corrections from the 'calexp' connection.",
208 doPreWarpInterpolation = Field[bool](
209 doc=
"Interpolate over bad pixels before warping?",
213 doc=
"Interpolation task to use for pre-warping interpolation",
214 target=CloughTocher2DInterpolateTask,
217 doc=
"Subtask that helps fill CoaddInputs catalogs added to the final "
219 target=CoaddInputRecorderTask,
221 includeCalibVar = Field[bool](
222 doc=
"Add photometric calibration variance to warp variance plane?",
225 matchingKernelSize = Field[int](
226 doc=
"Size in pixels of matching kernel. Must be odd.",
228 check=
lambda x: x % 2 == 1,
231 doc=
"Configuration for the warper that warps the image and noise",
232 dtype=Warper.ConfigClass,
235 doc=
"Configuration for the warp that warps the mask fraction image",
236 dtype=Warper.ConfigClass,
239 doc=
"Configuration for CoaddPsf",
240 dtype=CoaddPsfConfig,
242 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
247 def bgSubtracted(self) -> bool:
248 return not self.doRevertOldBackground
251 def bgSubtracted(self, value: bool) ->
None:
252 self.doRevertOldBackground = ~value
255 def doApplySkyCorr(self) -> bool:
256 return self.doApplyNewBackground
258 @doApplySkyCorr.setter
259 def doApplySkyCorr(self, value: bool) ->
None:
260 self.doApplyNewBackground = value
262 def setDefaults(self) -> None:
263 super().setDefaults()
264 self.warper.warpingKernelName =
"lanczos3"
265 self.maskedFractionWarper.warpingKernelName =
"bilinear"
268class MakeDirectWarpTask(PipelineTask):
269 """Warp single-detector images onto a common projection.
271 This task iterates over multiple images (corresponding to different
272 detectors) from a single visit that overlap the target patch. Pixels that
273 receive no input from any detector are set to NaN in the output image, and
274 NO_DATA bit is set in the mask plane.
276 This differs from the standard `MakeWarp` Task in the following
279 1. No selection on ccds at the time of warping. This is done later during
280 the coaddition stage.
281 2. Interpolate over a set of masked pixels before warping.
282 3. Generate an image where each pixel denotes how much of the pixel is
284 4. Generate multiple noise warps with the same interpolation applied.
285 5. No option to produce a PSF-matched warp.
288 ConfigClass = MakeDirectWarpConfig
289 _DefaultName =
"makeDirectWarp"
291 def __init__(self, **kwargs):
292 super().__init__(**kwargs)
293 self.makeSubtask(
"inputRecorder")
294 self.makeSubtask(
"preWarpInterpolation")
296 self.warper = Warper.fromConfig(self.config.warper)
297 self.maskedFractionWarper = Warper.fromConfig(self.config.maskedFractionWarper)
299 def runQuantum(self, butlerQC, inputRefs, outputRefs):
303 inputs = butlerQC.get(inputRefs)
305 if not inputs[
"calexp_list"]:
306 raise NoWorkFound(
"No input warps provided for co-addition")
308 sky_map = inputs.pop(
"sky_map")
310 quantumDataId = butlerQC.quantum.dataId
311 sky_info = makeSkyInfo(
313 tractId=quantumDataId[
"tract"],
314 patchId=quantumDataId[
"patch"],
317 visit_summary = inputs[
"visit_summary"]
if self.config.useVisitSummaryPsf
else None
319 results = self.run(inputs, sky_info, visit_summary)
320 butlerQC.put(results, outputRefs)
322 def run(self, inputs, sky_info, visit_summary):
323 """Create a Warp dataset from inputs.
328 Dictionary of input datasets. It must have a list of input calexps
329 under the key "calexp_list". Other supported keys are
330 "background_revert_list" and "background_apply_list", corresponding
331 to the old and the new backgrounds to be reverted and applied to
332 the calexps. They must be in the same order as the calexps.
333 sky_info : `~lsst.pipe.base.Struct`
334 A Struct object containing wcs, bounding box, and other information
335 about the patches within the tract.
336 visit_summary : `~lsst.afw.table.ExposureCatalog` | None
337 Table of visit summary information. If provided, the visit summary
338 information will be used to update the calibration of the input
339 exposures. If None, the input exposures will be used as-is.
343 results : `~lsst.pipe.base.Struct`
344 A Struct object containing the warped exposure, noise exposure(s),
345 and masked fraction image.
347 target_bbox, target_wcs = sky_info.bbox, sky_info.wcs
350 final_warp = ExposureF(target_bbox, target_wcs)
352 exposures = inputs[
"calexp_list"]
353 background_revert_list = inputs.get(
"background_revert_list", [
None] * len(exposures))
354 background_apply_list = inputs.get(
"background_apply_list", [
None] * len(exposures))
356 visit_id = exposures[0].dataId[
"visit"]
363 final_warp = self._prepareEmptyExposure(sky_info)
364 final_masked_fraction_warp = self._prepareEmptyExposure(sky_info)
365 final_noise_warps = {
366 n_noise: self._prepareEmptyExposure(sky_info)
367 for n_noise
in range(self.config.numberOfNoiseRealizations)
372 inputRecorder = self.inputRecorder.makeCoaddTempExpRecorder(
377 for index, (calexp_ref, old_background, new_background)
in enumerate(
378 zip(exposures, background_revert_list, background_apply_list, strict=
True)
380 dataId = calexp_ref.dataId
382 "Warping exposure %d/%d for id=%s",
387 calexp = calexp_ref.get()
390 seed = self.get_seed_from_data_id(dataId)
391 rng = np.random.RandomState(seed + self.config.seedOffset)
394 noise_calexps = self.make_noise_exposures(calexp, rng)
397 xyTransform = makeWcsPairTransform(calexp.getWcs(), target_wcs)
398 psfWarped =
WarpedPsf(calexp.getPsf(), xyTransform)
400 warpedExposure = self.process(
407 destBBox=target_bbox,
409 warpedExposure.setPsf(psfWarped)
412 nGood = copyGoodPixels(
413 final_warp.maskedImage,
414 warpedExposure.maskedImage,
415 final_warp.mask.getPlaneBitMask([
"NO_DATA"]),
417 ccdId = self.config.idGenerator.apply(dataId).catalog_id
418 inputRecorder.addCalExp(calexp, ccdId, nGood)
419 totalGoodPixels += nGood
422 if self.config.doPreWarpInterpolation:
423 badMaskPlanes = self.preWarpInterpolation.config.badMaskPlanes
426 masked_fraction_exp = self._get_bad_mask(calexp, badMaskPlanes)
427 masked_fraction_warp = self.maskedFractionWarper.warpExposure(
428 target_wcs, masked_fraction_exp, destBBox=target_bbox
431 final_masked_fraction_warp.maskedImage,
432 masked_fraction_warp.maskedImage,
433 final_masked_fraction_warp.mask.getPlaneBitMask([
"NO_DATA"]),
437 for n_noise
in range(self.config.numberOfNoiseRealizations):
438 noise_calexp = noise_calexps[n_noise]
439 warpedNoise = self.process(
446 destBBox=target_bbox,
449 final_noise_warps[n_noise].maskedImage,
450 warpedNoise.maskedImage,
451 final_noise_warps[n_noise].mask.getPlaneBitMask([
"NO_DATA"]),
455 if totalGoodPixels > 0:
456 inputRecorder.finish(final_warp, totalGoodPixels)
459 inputRecorder.coaddInputs.ccds,
461 self.config.coaddPsf.makeControl(),
464 final_warp.setPsf(coaddPsf)
465 final_warp.setFilter(calexp.getFilter())
466 final_warp.getInfo().setVisitInfo(calexp.getInfo().getVisitInfo())
470 masked_fraction_warp=final_masked_fraction_warp.image,
473 for noise_index, noise_exposure
in final_noise_warps.items():
474 setattr(results, f
"noise_warp{noise_index}", noise_exposure.maskedImage)
489 """Process an exposure.
491 There are three processing steps that are applied to the input:
493 1. Interpolate over bad pixels before warping.
494 2. Apply all calibrations from visit_summary to the exposure.
495 3. Warp the exposure to the target coordinate system.
499 exposure : `~lsst.afw.image.Exposure`
500 The input exposure to be processed.
501 target_wcs : `~lsst.afw.geom.SkyWcs`
502 The WCS of the target patch.
503 warper : `~lsst.afw.math.Warper`
504 The warper to use for warping the input exposure.
505 old_background : `~lsst.afw.image.Background` | None
506 The old background to be added back into the calexp.
507 new_background : `~lsst.afw.image.Background` | None
508 The new background to be subtracted from the calexp.
509 visit_summary : `~lsst.afw.table.ExposureCatalog` | None
510 Table of visit summary information. If not None, the visit_summary
511 information will be used to update the calibration of the input
512 exposures. Otherwise, the input exposures will be used as-is.
513 maxBBox : `~lsst.geom.Box2I` | None
514 Maximum bounding box of the warped exposure. If None, this is
515 determined automatically.
516 destBBox : `~lsst.geom.Box2I` | None
517 Exact bounding box of the warped exposure. If None, this is
518 determined automatically.
522 warped_exposure : `~lsst.afw.image.Exposure`
523 The processed and warped exposure.
526 if self.config.doPreWarpInterpolation:
527 self.preWarpInterpolation.run(exposure.maskedImage)
529 self._apply_all_calibrations(
534 visit_summary=visit_summary,
535 includeScaleUncertainty=self.config.includeCalibVar,
537 with self.timer(
"warp"):
538 warped_exposure = warper.warpExposure(
547 return warped_exposure
550 def _apply_all_calibrations(
555 visit_summary: ExposureCatalog |
None =
None,
556 includeScaleUncertainty: bool =
False,
558 """Apply all of the calibrations from visit_summary to the exposure.
560 Specifically, this method updates the following (if available) to the
561 input exposure ``exp`` in place from ``visit_summary``:
563 - Aperture correction map
564 - Photometric calibration
570 exp : `~lsst.afw.image.Exposure`
571 Exposure to be updated.
572 old_background : `~lsst.afw.image.Exposure`
573 Exposure corresponding to the old background, to be added back.
574 new_background : `~lsst.afw.image.Exposure`
575 Exposure corresponding to the new background, to be subtracted.
576 logger : `logging.Logger`
577 Logger object from the caller Task to write the logs onto.
578 visit_summary : `~lsst.afw.table.ExposureCatalog` | None
579 Table of visit summary information. If not None, the visit summary
580 information will be used to update the calibration of the input
581 exposures. Otherwise, the input exposures will be used as-is.
582 includeScaleUncertainty : bool
583 Whether to include the uncertainty on the calibration in the
584 resulting variance? Passed onto the `calibrateImage` method of the
585 PhotoCalib object attached to ``exp``.
590 Raised if ``visit_summary`` is provided but does not contain a
591 record corresponding to ``exp``.
593 if not visit_summary:
594 logger.debug(
"No visit summary provided.")
596 logger.debug(
"Updating calibration from visit summary.")
599 exp.maskedImage += old_background.getImage()
602 detector = exp.info.getDetector().getId()
603 row = visit_summary.find(detector)
606 raise RuntimeError(f
"Unexpectedly incomplete visit_summary: {detector=} is missing.")
608 if photo_calib := row.getPhotoCalib():
609 exp.setPhotoCalib(photo_calib)
612 "No photometric calibration found in visit summary for detector = %s.",
616 if wcs := row.getWcs():
619 logger.warning(
"No WCS found in visit summary for detector = %s.", detector)
621 if psf := row.getPsf():
624 logger.warning(
"No PSF found in visit summary for detector = %s.", detector)
626 if apcorr_map := row.getApCorrMap():
627 exp.setApCorrMap(apcorr_map)
630 "No aperture correction map found in visit summary for detector = %s.",
635 exp.maskedImage -= new_background.getImage()
639 photo_calib = exp.getPhotoCalib()
640 exp.maskedImage = photo_calib.calibrateImage(
641 exp.maskedImage, includeScaleUncertainty=includeScaleUncertainty
643 exp.maskedImage /= photo_calib.getCalibrationMean()
647 def _prepareEmptyExposure(cls, sky_info):
648 """Produce an empty exposure for a given patch.
652 sky_info : `lsst.pipe.base.Struct`
653 Struct from `~lsst.pipe.base.coaddBase.makeSkyInfo` with
654 geometric information about the patch.
658 exp : `lsst.afw.image.exposure.ExposureF`
659 An empty exposure for a given patch.
661 exp = ExposureF(sky_info.bbox, sky_info.wcs)
662 exp.getMaskedImage().
set(np.nan, Mask.getPlaneBitMask(
"NO_DATA"), np.inf)
666 def compute_median_variance(mi: MaskedImage) -> float:
667 """Compute the median variance across the good pixels of a MaskedImage.
671 mi : `~lsst.afw.image.MaskedImage`
672 The input image on which to compute the median variance.
676 median_variance : `float`
677 Median variance of the input calexp.
681 return np.median(mi.variance.array[np.isfinite(mi.variance.array) & np.isfinite(mi.image.array)])
683 def get_seed_from_data_id(self, data_id) -> int:
684 """Get a seed value given a data_id.
686 This method generates a unique, reproducible pseudo-random number for
687 a data id. This is not affected by ordering of the input, or what
688 set of visits, ccds etc. are given.
690 This is implemented as a public method, so that simulations that
691 don't necessary deal with the middleware can mock up a ``data_id``
692 instance, or override this method with a different one to obtain a
693 seed value consistent with the pipeline task.
697 data_id : `~lsst.daf.butler.DataCoordinate`
698 Data identifier dictionary.
703 A unique seed for this data_id to seed a random number generator.
705 return self.config.idGenerator.apply(data_id).catalog_id
707 def make_noise_exposures(self, calexp: ExposureF, rng) -> dict[int, ExposureF]:
708 """Make pure noise realizations based on ``calexp``.
712 calexp : `~lsst.afw.image.ExposureF`
713 The input exposure on which to base the noise realizations.
714 rng : `np.random.RandomState`
715 Random number generator to use for the noise realizations.
719 noise_calexps : `dict` [`int`, `~lsst.afw.image.ExposureF`]
720 A mapping of integers ranging from 0 up to
721 config.numberOfNoiseRealizations to the corresponding
722 noise realization exposures.
728 if self.config.numberOfNoiseRealizations == 0:
731 if self.config.useMedianVariance:
732 variance = self.compute_median_variance(calexp.maskedImage)
734 variance = calexp.variance.array
736 for n_noise
in range(self.config.numberOfNoiseRealizations):
737 noise_calexp = calexp.clone()
738 noise_calexp.image.array[:, :] = rng.normal(
739 scale=np.sqrt(variance),
740 size=noise_calexp.image.array.shape,
742 noise_calexp.variance.array[:, :] = variance
743 noise_calexps[n_noise] = noise_calexp
748 def _get_bad_mask(cls, exp: ExposureF, badMaskPlanes: Iterable[str]) -> ExposureF:
749 """Get an Exposure of bad mask
753 exp: `lsst.afw.image.Exposure`
755 badMaskPlanes: `list` [`str`]
756 List of mask planes to be considered as bad.
760 bad_mask: `~lsst.afw.image.Exposure`
761 An Exposure with boolean array with True if inverse variance <= 0
762 or if any of the badMaskPlanes bits are set, and False otherwise.
765 bad_mask = exp.clone()
767 var = exp.variance.array
768 mask = exp.mask.array
770 bitMask = exp.mask.getPlaneBitMask(badMaskPlanes)
772 bad_mask.image.array[:, :] = (var < 0) | np.isinf(var) | ((mask & bitMask) != 0)
774 bad_mask.variance.array *= 0.0
CoaddPsf is the Psf derived to be used for non-PSF-matched Coadd images.
A Psf class that maps an arbitrary Psf through a coordinate transformation.
daf::base::PropertySet * set