lsst.ip.diffim gb9ad5b632f+e689cc2d2f
Loading...
Searching...
No Matches
getTemplate.py
Go to the documentation of this file.
1# This file is part of ip_diffim.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21import collections
22import dataclasses
23
24import numpy as np
25
26import lsst.afw.image as afwImage
27import lsst.geom as geom
28import lsst.afw.geom as afwGeom
29from lsst.afw.image import VisitInfo
30import lsst.afw.table as afwTable
31from lsst.afw.math._warper import computeWarpedBBox
32import lsst.afw.math as afwMath
33import lsst.pex.config as pexConfig
34import lsst.pipe.base as pipeBase
35
36from lsst.skymap import BaseSkyMap
37from lsst.ip.diffim.dcrModel import DcrModel
38from lsst.meas.algorithms import CoaddPsf, CoaddPsfConfig, SubtractBackgroundTask, ScaleVarianceTask
39from lsst.utils.timer import timeMethod
40
41__all__ = [
42 "GetTemplateTask",
43 "GetTemplateConfig",
44 "GetDcrTemplateTask",
45 "GetDcrTemplateConfig",
46]
47
48
50 pipeBase.PipelineTaskConnections,
51 dimensions=("instrument", "visit", "detector"),
52 defaultTemplates={"coaddName": "goodSeeing", "warpTypeSuffix": "", "fakesType": ""},
53):
54 bbox = pipeBase.connectionTypes.Input(
55 doc="Bounding box of exposure to determine the geometry of the output template.",
56 name="{fakesType}calexp.bbox",
57 storageClass="Box2I",
58 dimensions=("instrument", "visit", "detector"),
59 )
60 wcs = pipeBase.connectionTypes.Input(
61 doc="WCS of the exposure that we will construct the template for.",
62 name="{fakesType}calexp.wcs",
63 storageClass="Wcs",
64 dimensions=("instrument", "visit", "detector"),
65 )
66 skyMap = pipeBase.connectionTypes.Input(
67 doc="Geometry of the tracts and patches that the coadds are defined on.",
68 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
69 dimensions=("skymap",),
70 storageClass="SkyMap",
71 )
72 coaddExposures = pipeBase.connectionTypes.Input(
73 doc="Coadds that may overlap the desired region, as possible inputs to the template."
74 " Will be restricted to those that directly overlap the projected bounding box.",
75 dimensions=("tract", "patch", "skymap", "band"),
76 storageClass="ExposureF",
77 name="{fakesType}{coaddName}Coadd{warpTypeSuffix}",
78 multiple=True,
79 deferLoad=True,
80 deferGraphConstraint=True,
81 )
82
83 template = pipeBase.connectionTypes.Output(
84 doc="Warped template, pixel matched to the bounding box and WCS.",
85 dimensions=("instrument", "visit", "detector"),
86 storageClass="ExposureF",
87 name="{fakesType}{coaddName}Diff_templateExp{warpTypeSuffix}",
88 )
89
90 def __init__(self, *, config=None):
91 super().__init__(config=config)
92 if config.requireCoaddAtGraphBuild:
93 self.coaddExposures = dataclasses.replace(
94 self.coaddExposures,
95 deferGraphConstraint=False,
96 )
97
98
99class GetTemplateConfig(
100 pipeBase.PipelineTaskConfig, pipelineConnections=GetTemplateConnections
101):
102 templateBorderSize = pexConfig.Field(
103 dtype=int,
104 default=20,
105 doc="Number of pixels to grow the requested template image to account for warping",
106 )
107 warp = pexConfig.ConfigField(
108 dtype=afwMath.Warper.ConfigClass,
109 doc="warper configuration",
110 )
111 doCorrectVariancePlateScale = pexConfig.Field(
112 dtype=bool,
113 default=False,
114 doc="Correct the template variance for the total change in pixel"
115 " area between the images the coadds were built from and the science"
116 " image? This matters when the two have different plate scales, such"
117 " as DECam templates used for Rubin science images. The coadd pixel"
118 " grid cancels out of the total, so the skymap does not enter. It is a"
119 " single scalar factor, applied to the final template variance plane.",
120 )
121 coaddPsf = pexConfig.ConfigField(
122 doc="Configuration for CoaddPsf",
123 dtype=CoaddPsfConfig,
124 )
125 varianceBackground = pexConfig.ConfigurableField(
126 target=SubtractBackgroundTask,
127 doc="Task to estimate the background variance.",
128 )
129 highVarianceThreshold = pexConfig.RangeField(
130 dtype=float,
131 default=4,
132 min=1,
133 doc="Set the HIGH_VARIANCE mask plane for regions with variance"
134 " greater than the median by this factor.",
135 )
136 highVarianceMaskFraction = pexConfig.Field(
137 dtype=float,
138 default=0.1,
139 doc="Minimum fraction of unmasked pixels needed to set the"
140 " HIGH_VARIANCE mask plane.",
141 )
142 doScaleVariance = pexConfig.Field(
143 dtype=bool,
144 default=True,
145 doc="Scale variance of the template image?"
146 )
147 scaleVariance = pexConfig.ConfigurableField(
148 target=ScaleVarianceTask,
149 doc="Subtask to rescale the variance of the template to the statistically expected level."
150 )
151 requireCoaddAtGraphBuild = pexConfig.Field(
152 dtype=bool,
153 default=False,
154 doc="If True, include the coadd dataset existence in the"
155 " initial butler query during QuantumGraph generation.",
156 )
157
158 def setDefaults(self):
159 # Use a smaller cache: per SeparableKernel.computeCache, this should
160 # give a warping error of a fraction of a count (these must match).
161 self.warp.cacheSize = 100000
162 self.coaddPsf.cacheSize = self.warp.cacheSize
163 # The WCS for LSST should be smoothly varying, so we can use a longer
164 # interpolation length for WCS evaluations.
165 self.warp.interpLength = 100
166 self.warp.warpingKernelName = "lanczos3"
167 self.coaddPsf.warpingKernelName = self.warp.warpingKernelName
168
169 # Background subtraction of the variance plane
170 self.varianceBackground.algorithm = "LINEAR"
171 self.varianceBackground.binSize = 32
172 self.varianceBackground.useApprox = False
173 self.varianceBackground.statisticsProperty = "MEDIAN"
174 self.varianceBackground.doFilterSuperPixels = True
175 self.varianceBackground.ignoredPixelMask = ["BAD",
176 "EDGE",
177 "DETECTED",
178 "DETECTED_NEGATIVE",
179 "NO_DATA",
180 ]
181
182
183class GetTemplateTask(pipeBase.PipelineTask):
184 ConfigClass = GetTemplateConfig
185 _DefaultName = "getTemplate"
186
187 def __init__(self, *args, **kwargs):
188 super().__init__(*args, **kwargs)
189 if self.config.doScaleVariance:
190 self.makeSubtask("scaleVariance")
191 self.warper = afwMath.Warper.fromConfig(self.config.warp)
192 self.schema = afwTable.ExposureTable.makeMinimalSchema()
193 self.schema.addField(
194 "tract", type=np.int32, doc="Which tract this exposure came from."
195 )
196 self.schema.addField(
197 "patch",
198 type=np.int32,
199 doc="Which patch in the tract this exposure came from.",
200 )
201 self.schema.addField(
202 "weight",
203 type=float,
204 doc="Weight for each exposure, used to make the CoaddPsf; should always be 1.",
205 )
206 self.makeSubtask("varianceBackground")
207
208 def runQuantum(self, butlerQC, inputRefs, outputRefs):
209 inputs = butlerQC.get(inputRefs)
210 bbox = inputs.pop("bbox")
211 wcs = inputs.pop("wcs")
212 coaddExposures = inputs.pop("coaddExposures")
213 skymap = inputs.pop("skyMap")
214
215 # This should not happen with a properly configured execution context.
216 assert not inputs, "runQuantum got more inputs than expected"
217
218 results = self.getExposures(coaddExposures, bbox, skymap, wcs)
219 physical_filter = butlerQC.quantum.dataId["physical_filter"]
220 outputs = self.run(
221 coaddExposureHandles=results.coaddExposures,
222 bbox=bbox,
223 wcs=wcs,
224 dataIds=results.dataIds,
225 physical_filter=physical_filter,
226 visit=outputRefs.template.dataId["visit"],
227 )
228 butlerQC.put(outputs, outputRefs)
229
230 def getExposures(self, coaddExposureHandles, bbox, skymap, wcs):
231 """Return a data structure containing the coadds that overlap the
232 specified bbox projected onto the sky, and a corresponding data
233 structure of their dataIds.
234 These are the appropriate inputs to this task's `run` method.
235
236 The spatial index in the butler registry has generous padding and often
237 supplies patches near, but not directly overlapping the desired region.
238 This method filters the inputs so that `run` does not have to read in
239 all possibly-matching coadd exposures.
240
241 Parameters
242 ----------
243 coaddExposureHandles : `iterable` \
244 [`lsst.daf.butler.DeferredDatasetHandle` of \
245 `lsst.afw.image.Exposure`]
246 Dataset handles to exposures that might overlap the desired
247 region.
248 bbox : `lsst.geom.Box2I`
249 Template bounding box of the pixel geometry onto which the
250 coaddExposures will be resampled.
251 skymap : `lsst.skymap.SkyMap`
252 Geometry of the tracts and patches the coadds are defined on.
253 wcs : `lsst.afw.geom.SkyWcs`
254 Template WCS onto which the coadds will be resampled.
255
256 Returns
257 -------
258 result : `lsst.pipe.base.Struct`
259 A struct with attributes:
260
261 ``coaddExposures``
262 Dict of coadd exposures that overlap the projected bbox,
263 indexed on tract id
264 (`dict` [`int`, `list` [`lsst.daf.butler.DeferredDatasetHandle` of
265 `lsst.afw.image.Exposure`] ]).
266 ``dataIds``
267 Dict of data IDs of the coadd exposures that overlap the
268 projected bbox, indexed on tract id
269 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
270
271 Raises
272 ------
273 NoWorkFound
274 Raised if no patches overlap the input detector bbox, or the input
275 WCS is None.
276 """
277 if wcs is None:
278 raise pipeBase.NoWorkFound(
279 "WCS is None; cannot find overlapping exposures."
280 )
281
282 # Exposure's validPolygon would be more accurate
283 detectorPolygon = geom.Box2D(bbox)
284 detectorCorners = wcs.pixelToSky(detectorPolygon.getCorners())
285 overlappingArea = 0
286 coaddExposures = collections.defaultdict(list)
287 dataIds = collections.defaultdict(list)
288
289 for coaddRef in coaddExposureHandles:
290 dataId = coaddRef.dataId
291 patchWcs = skymap[dataId["tract"]].getWcs()
292 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
293 patchPolygon = afwGeom.Polygon(geom.Box2D(patchBBox))
294 # Calculate detector/patch overlap in patch coordinates rather than
295 # detector coordinates because the skymap's inverse mapping
296 # (patchWcs.skyToPixel()) is more stable than the detector's for
297 # arbitrary sky coordinates.
298 detectorInPatchCoordinates = afwGeom.Polygon(patchWcs.skyToPixel(detectorCorners))
299 if patchPolygon.intersection(detectorInPatchCoordinates):
300 overlappingArea += patchPolygon.intersectionSingle(
301 detectorInPatchCoordinates
302 ).calculateArea()
303 self.log.info(
304 "Using template input tract=%s, patch=%s",
305 dataId["tract"],
306 dataId["patch"],
307 )
308 coaddExposures[dataId["tract"]].append(coaddRef)
309 dataIds[dataId["tract"]].append(dataId)
310
311 if not overlappingArea:
312 raise pipeBase.NoWorkFound("No patches overlap detector")
313
314 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
315
316 @timeMethod
317 def run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None):
318 """Warp coadds from multiple tracts and patches to form a template to
319 subtract from a science image.
320
321 Tract and patch overlap regions are combined by a variance-weighted
322 average, and the variance planes are combined with the same weights,
323 not added in quadrature; the overlap regions are not statistically
324 independent, because they're derived from the same original data.
325 The PSF on the template is created by combining the CoaddPsf on each
326 template image into a meta-CoaddPsf.
327
328 Parameters
329 ----------
330 coaddExposureHandles : `dict` [`int`, `list` of \
331 [`lsst.daf.butler.DeferredDatasetHandle` of \
332 `lsst.afw.image.Exposure`]]
333 Coadds to be mosaicked, indexed on tract id.
334 bbox : `lsst.geom.Box2I`
335 Template Bounding box of the detector geometry onto which to
336 resample the ``coaddExposureHandles``. Modified in-place to include the
337 template border.
338 wcs : `lsst.afw.geom.SkyWcs`
339 Template WCS onto which to resample the ``coaddExposureHandles``.
340 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
341 Record of the tract and patch of each coaddExposure, indexed on
342 tract id.
343 physical_filter : `str`
344 Physical filter of the science image.
345 visit : `int`, optional
346 If supplied, over-write the visit ID in the template's visitInfo
347 so that downstream source injection tasks can link the template and
348 science image for the visit.
349
350 Returns
351 -------
352 result : `lsst.pipe.base.Struct`
353 A struct with attributes:
354
355 ``template``
356 A template coadd exposure assembled out of patches, with
357 pixels in nJy (`lsst.afw.image.ExposureF`).
358
359 Raises
360 ------
361 NoWorkFound
362 If no coadds are found with sufficient un-masked pixels.
363 """
364 band, photoCalib = self._checkInputs(dataIds, coaddExposureHandles)
365
366 bbox.grow(self.config.templateBorderSize)
367
368 warped = {}
369 catalogs = []
370 # Determine the ratio of the original pixel area to the pixel area of
371 # the science image, if configured. This will only be different from 1
372 # if the coadd was from a different instrument. The ratio can be
373 # determined once from the components of one coadd exposure, so that no
374 # pixels have to be read yet. Coadds comprising images from multiple
375 # different instruments are not supported.
376 plateScaleFactor = None
377 if self.config.doCorrectVariancePlateScale and coaddExposureHandles:
378 plateScaleFactor = self._plateScaleFactor(
379 next(iter(coaddExposureHandles.values()))[0], wcs, bbox
380 )
381
382 for tract in coaddExposureHandles:
383 maskedImages, catalog, totalBox = self._makeExposureCatalog(
384 coaddExposureHandles[tract], dataIds[tract]
385 )
386 warpedBox = computeWarpedBBox(catalog[0].wcs, bbox, wcs)
387 warpedBox.grow(5) # to ensure we catch all relevant input pixels
388 # Combine images from individual patches together.
389 unwarped, count, included = self._merge(
390 maskedImages, warpedBox, catalog[0].wcs
391 )
392 # Delete `maskedImages` after combining into one large image to reduce peak memory use
393 del maskedImages
394 if count == 0:
395 self.log.info(
396 "No valid pixels from coadd patches in tract %s; not including in output.",
397 tract,
398 )
399 continue
400 warpedBox.clip(totalBox)
401 potentialInput = self.warper.warpExposure(
402 wcs, unwarped.subset(warpedBox), destBBox=bbox
403 )
404 # Delete the single large `unwarped` image after warping to reduce peak memory use
405 del unwarped
406 if np.all(
407 potentialInput.mask.array
408 & potentialInput.mask.getPlaneBitMask("NO_DATA")
409 ):
410 self.log.info(
411 "No overlap from coadd patches in tract %s; not including in output.",
412 tract,
413 )
414 continue
415
416 # Trim the exposure catalog to just the patches that were used.
417 tempCatalog = afwTable.ExposureCatalog(self.schema)
418 tempCatalog.reserve(len(included))
419 for i in included:
420 tempCatalog.append(catalog[i])
421 catalogs.append(tempCatalog)
422 warped[tract] = potentialInput.maskedImage
423
424 if len(warped) == 0:
425 raise pipeBase.NoWorkFound("No patches found to overlap science exposure.")
426
427 # At this point, all entries will be valid, so we can ignore included.
428 template, count, _ = self._merge(warped, bbox, wcs)
429 if count == 0:
430 raise pipeBase.NoWorkFound("No valid pixels in warped template.")
431
432 if self.config.doScaleVariance:
433 # Scale the variance of the template image before subtraction, if
434 # needed. Note that the science variance is scaled
435 # independently in ``AlardLuptonSubtractTask``.
436 varianceFactor = self.scaleVariance.run(template.maskedImage)
437 self.log.info("Template variance scaling factor: %.2f", varianceFactor)
438 self.metadata["scaleTemplateVarianceFactor"] = varianceFactor
439
440 self._correctVariance(template, plateScaleFactor)
441
442 # Make a single catalog containing all the inputs that were accepted.
443 catalog = afwTable.ExposureCatalog(self.schema)
444 catalog.reserve(sum([len(c) for c in catalogs]))
445 for c in catalogs:
446 catalog.extend(c)
447
448 # Set a mask plane for any regions with exceptionally high variance.
449 self.checkHighVariance(template)
450 if visit is not None:
451 template.getInfo().setVisitInfo(VisitInfo(id=visit))
452 template.setFilter(afwImage.FilterLabel(band, physical_filter))
453 identity = afwImage.PhotoCalib(1.0)
454 if photoCalib is not None and photoCalib != identity:
455 template.maskedImage = photoCalib.calibrateImage(template.maskedImage)
456 photoCalib = identity
457 template.setPhotoCalib(photoCalib)
458 template.setPsf(self._makePsf(template, catalog, wcs))
459
460 # Record the input coadd patches as the template's coadd inputs.
461 coaddInputs = afwImage.CoaddInputs(afwTable.ExposureTable.makeMinimalSchema(), self.schema)
462 coaddInputs.ccds.extend(catalog, deep=True)
463 template.getInfo().setCoaddInputs(coaddInputs)
464 return pipeBase.Struct(template=template)
465
466 def _correctVariance(self, template, plateScaleFactor):
467 """Correct the template variance plane for the effects of warping.
468
469 Compute a multiplicative correction factor for the template variance
470 if the plate scale of the coadd's constituent images is different than
471 the science image the template is being constructed for. This should
472 only be necessary if the coadd images were from a different instrument
473 than the science image. If the plate scale of the science instrument is
474 smaller than the plate scale of the coadd instrument, then the template
475 pixels will be correlated and the true variance will be higher than the
476 image pixel noise level would suggest.
477
478 Parameters
479 ----------
480 template : `lsst.afw.image.Exposure`
481 Assembled template; its variance plane is modified in place.
482 plateScaleFactor : `float` or `None`
483 Correction for the change in pixel area, from `_plateScaleFactor`.
484
485 Raises
486 ------
487 RuntimeError
488 Raised if ``doCorrectVariancePlateScale`` is set but the factor
489 could not be reconstructed from the coadd inputs.
490 """
491 scale = 1.0
492 if self.config.doCorrectVariancePlateScale:
493 if plateScaleFactor is None:
494 raise RuntimeError(
495 "doCorrectVariancePlateScale is set but no usable coaddInputs were found."
496 )
497 scale *= plateScaleFactor
498 self.metadata["variancePlateScaleFactor"] = plateScaleFactor
499 self.log.info(
500 "Applying a plate scale variance factor of %.4f, reconstructed from the"
501 " coadd inputs.",
502 plateScaleFactor,
503 )
504
505 if scale == 1.0:
506 return
507 template.variance.array *= scale
508 self.metadata["templateVarianceCorrectionFactor"] = scale
509 self.log.info(
510 "Corrected the template variance plane for warping by a factor of %.4f.",
511 scale,
512 )
513
514 def _plateScaleFactor(self, coaddHandle, wcs, bbox):
515 """Compute the variance correction for the total change in pixel area
516 between the coadd's constituent images and the science image.
517
518 Parameters
519 ----------
520 coaddHandle : `lsst.daf.butler.DeferredDatasetHandle` of \
521 `lsst.afw.image.Exposure`
522 Handle to one of the input coadd patches. Only its ``coaddInputs``
523 component is read; the pixels are left alone.
524 wcs : `lsst.afw.geom.SkyWcs`
525 WCS of the science image the template is being built for.
526 bbox : `lsst.geom.Box2I`
527 Bounding box of the template, used only to choose where to
528 evaluate the science image pixel scale.
529
530 Returns
531 -------
532 factor : `float` or `None`
533 The correction factor equivalent to the ratio of the area of a pixel
534 from the coadd's instrument to the science instrument, or `None` if
535 this patch carries no input record from which it could be computed.
536 """
537 coaddInputs = coaddHandle.get(component="coaddInputs")
538 if coaddInputs is None:
539 return None
540 scienceScale = wcs.getPixelScale(geom.Box2D(bbox).getCenter()).asArcseconds()
541
542 for record in coaddInputs.ccds:
543 # Iterate through the records, and use the first one with a WCS
544 # that gives a usable pixel area.
545 originalWcs = record.getWcs()
546 if originalWcs is None:
547 continue
548 center = geom.Box2D(record.getBBox()).getCenter()
549 pviScale = originalWcs.getPixelScale(center).asArcseconds()
550 # The area of one original pixel, expressed in science pixels.
551 factor = (pviScale/scienceScale)**2
552 if np.isfinite(factor) and factor > 0:
553 return factor
554 return None
555
556 def checkHighVariance(self, template):
557 """Set a mask plane for regions with unusually high variance.
558
559 Parameters
560 ----------
561 template : `lsst.afw.image.Exposure`
562 The warped template exposure, which will be modified in place.
563 """
564 highVarianceMaskPlaneBit = template.mask.addMaskPlane("HIGH_VARIANCE")
565 ignoredPixelBits = template.mask.getPlaneBitMask(self.varianceBackground.config.ignoredPixelMask)
566 goodMask = (template.mask.array & ignoredPixelBits) == 0
567 goodFraction = np.count_nonzero(goodMask)/template.mask.array.size
568 if goodFraction < self.config.highVarianceMaskFraction:
569 self.log.info("Not setting HIGH_VARIANCE mask plane, only %2.1f%% of"
570 " pixels were unmasked for background estimation, but"
571 " %2.1f%% are required", 100*goodFraction, 100*self.config.highVarianceMaskFraction)
572 else:
573 varianceExposure = template.clone()
574 varianceExposure.image.array = varianceExposure.variance.array
575 varianceBackground = self.varianceBackground.run(varianceExposure).background.getImage().array
576 threshold = self.config.highVarianceThreshold*np.nanmedian(varianceBackground)
577 highVariancePix = varianceBackground > threshold
578 template.mask.array[highVariancePix] |= 2**highVarianceMaskPlaneBit
579
580 @staticmethod
581 def _checkInputs(dataIds, coaddExposures):
582 """Check that the all the dataIds are from the same band and that
583 the exposures all have the same photometric calibration.
584
585 Parameters
586 ----------
587 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
588 Record of the tract and patch of each coaddExposure.
589 coaddExposures : `dict` [`int`, `list` of \
590 [`lsst.daf.butler.DeferredDatasetHandle` of \
591 `lsst.afw.image.Exposure` or
592 `lsst.afw.image.Exposure`]]
593 Coadds to be mosaicked.
594
595 Returns
596 -------
597 band : `str`
598 Filter band of all the input exposures.
599 photoCalib : `lsst.afw.image.PhotoCalib`
600 Photometric calibration of all of the input exposures.
601
602 Raises
603 ------
604 RuntimeError
605 Raised if the bands or calibrations of the input exposures are not
606 all the same.
607 """
608 bands = set(dataId["band"] for tract in dataIds for dataId in dataIds[tract])
609 if len(bands) > 1:
610 raise RuntimeError(f"GetTemplateTask called with multiple bands: {bands}")
611 band = bands.pop()
612 photoCalibs = [
613 exposure.get(component="photoCalib")
614 for exposures in coaddExposures.values()
615 for exposure in exposures
616 ]
617 if not all([photoCalibs[0] == x for x in photoCalibs]):
618 msg = f"GetTemplateTask called with exposures with different photoCalibs: {photoCalibs}"
619 raise RuntimeError(msg)
620 photoCalib = photoCalibs[0]
621 return band, photoCalib
622
623 def _makeExposureCatalog(self, exposureRefs, dataIds):
624 """Make an exposure catalog for one tract.
625
626 Parameters
627 ----------
628 exposureRefs : `list` of [`lsst.daf.butler.DeferredDatasetHandle` of \
629 `lsst.afw.image.Exposure`]
630 Exposures to include in the catalog.
631 dataIds : `list` [`lsst.daf.butler.DataCoordinate`]
632 Data ids of each of the included exposures; must have "tract" and
633 "patch" entries.
634
635 Returns
636 -------
637 images : `dict` [`lsst.afw.image.MaskedImage`]
638 MaskedImages of each of the input exposures, for warping.
639 catalog : `lsst.afw.table.ExposureCatalog`
640 Catalog of metadata for each exposure
641 totalBox : `lsst.geom.Box2I`
642 The union of the bounding boxes of all the input exposures.
643 """
644 catalog = afwTable.ExposureCatalog(self.schema)
645 catalog.reserve(len(exposureRefs))
646 exposures = (exposureRef.get() for exposureRef in exposureRefs)
647 images = {}
648 totalBox = geom.Box2I()
649
650 for coadd, dataId in zip(exposures, dataIds):
651 images[dataId] = coadd.maskedImage
652 bbox = coadd.getBBox()
653 totalBox = totalBox.expandedTo(bbox)
654 record = catalog.addNew()
655 record.setPsf(coadd.psf)
656 record.setWcs(coadd.wcs)
657 record.setPhotoCalib(coadd.photoCalib)
658 record.setBBox(bbox)
659 record.setValidPolygon(afwGeom.Polygon(geom.Box2D(bbox).getCorners()))
660 record.set("tract", dataId["tract"])
661 record.set("patch", dataId["patch"])
662 # Weight is used by CoaddPsf, but the PSFs from overlapping patches
663 # should be very similar, so this value mostly shouldn't matter.
664 record.set("weight", 1)
665
666 return images, catalog, totalBox
667
668 def _merge(self, maskedImages, bbox, wcs):
669 """Merge the images that came from one tract into one larger image,
670 ignoring NaN pixels and non-finite variance pixels from individual
671 exposures.
672
673 Parameters
674 ----------
675 maskedImages : `dict` [`lsst.afw.image.MaskedImage` or
676 `lsst.afw.image.Exposure`]
677 Images to be merged into one larger bounding box.
678 bbox : `lsst.geom.Box2I`
679 Bounding box defining the image to merge into.
680 wcs : `lsst.afw.geom.SkyWcs`
681 WCS of all of the input images to set on the output image.
682
683 Returns
684 -------
685 merged : `lsst.afw.image.MaskedImage`
686 Merged image with all of the inputs at their respective bbox
687 positions.
688 count : `int`
689 Count of the number of good pixels (those with positive weights)
690 in the merged image.
691 included : `list` [`int`]
692 List of indexes of patches that were included in the merged
693 result, to be used to trim the exposure catalog.
694 """
695 merged = afwImage.ExposureF(bbox, wcs)
696 weights = afwImage.ImageF(bbox)
697 included = [] # which patches were included in the result
698 for i, (dataId, maskedImage) in enumerate(maskedImages.items()):
699 # Only merge into the trimmed box, to save memory
700 clippedBox = geom.Box2I(maskedImage.getBBox())
701 clippedBox.clip(bbox)
702 if clippedBox.area == 0:
703 self.log.debug("%s does not overlap template region.", dataId)
704 continue # nothing in this image overlaps the output
705 maskedImage = maskedImage.subset(clippedBox)
706 # Catch both zero-value and NaN variance plane pixels
707 good = (maskedImage.variance.array > 0) & (
708 np.isfinite(maskedImage.variance.array)
709 )
710 weight = maskedImage.variance.array[good] ** (-0.5)
711 bad = np.isnan(maskedImage.image.array) | ~good
712 # Note that modifying the patch MaskedImage in place is fine;
713 # we're throwing it away at the end anyway.
714 maskedImage.image.array[bad] = 0.0
715 maskedImage.variance.array[bad] = 0.0
716 # Reset mask, too, since these pixels don't contribute to sum.
717 maskedImage.mask.array[bad] = 0
718 # Cannot use `merged.maskedImage *= weight` because that operator
719 # multiplies the variance by the weight twice; in this case
720 # `weight` are the exact values we want to scale by.
721 maskedImage.image.array[good] *= weight
722 maskedImage.variance.array[good] *= weight
723 weights[clippedBox].array[good] += weight
724 # Free memory before creating new large arrays
725 del weight
726 merged.maskedImage[clippedBox] += maskedImage
727 included.append(i)
728
729 good = weights.array > 0
730
731 # Cannot use `merged.maskedImage /= weights` because that
732 # operator divides the variance by the weight twice; in this case
733 # `weights` are the exact values we want to scale by.
734 weights = weights.array[good]
735 merged.image.array[good] /= weights
736 merged.variance.array[good] /= weights
737
738 merged.mask.array[~good] |= merged.mask.getPlaneBitMask("NO_DATA")
739
740 return merged, good.sum(), included
741
742 def _makePsf(self, template, catalog, wcs):
743 """Return a PSF containing the PSF at each of the input regions.
744
745 Note that although this includes all the exposures from the catalog,
746 the PSF knows which part of the template the inputs came from, so when
747 evaluated at a given position it will not include inputs that never
748 went in to those pixels.
749
750 Parameters
751 ----------
752 template : `lsst.afw.image.Exposure`
753 Generated template the PSF is for.
754 catalog : `lsst.afw.table.ExposureCatalog`
755 Catalog of exposures that went into the template that contains all
756 of the input PSFs.
757 wcs : `lsst.afw.geom.SkyWcs`
758 WCS of the template, to warp the PSFs to.
759
760 Returns
761 -------
762 coaddPsf : `lsst.meas.algorithms.CoaddPsf`
763 The meta-psf constructed from all of the input catalogs.
764 """
765 # CoaddPsf centroid not only must overlap image, but must overlap the
766 # part of image with data. Use centroid of region with data.
767 boolmask = template.mask.array & template.mask.getPlaneBitMask("NO_DATA") == 0
768 maskx = afwImage.makeMaskFromArray(boolmask.astype(afwImage.MaskPixel))
769 centerCoord = afwGeom.SpanSet.fromMask(maskx, 1).computeCentroid()
770
771 ctrl = self.config.coaddPsf.makeControl()
772 coaddPsf = CoaddPsf(
773 catalog, wcs, centerCoord, ctrl.warpingKernelName, ctrl.cacheSize
774 )
775 return coaddPsf
776
777
779 GetTemplateConnections,
780 dimensions=("instrument", "visit", "detector"),
781 defaultTemplates={"coaddName": "dcr", "warpTypeSuffix": "", "fakesType": ""},
782):
783 visitInfo = pipeBase.connectionTypes.Input(
784 doc="VisitInfo of calexp used to determine observing conditions.",
785 name="{fakesType}calexp.visitInfo",
786 storageClass="VisitInfo",
787 dimensions=("instrument", "visit", "detector"),
788 )
789 dcrCoadds = pipeBase.connectionTypes.Input(
790 doc="Input DCR template to match and subtract from the exposure",
791 name="{fakesType}dcrCoadd{warpTypeSuffix}",
792 storageClass="ExposureF",
793 dimensions=("tract", "patch", "skymap", "band", "subfilter"),
794 multiple=True,
795 deferLoad=True,
796 )
797
798 def __init__(self, *, config=None):
799 super().__init__(config=config)
800 self.inputs.remove("coaddExposures")
801
802
803class GetDcrTemplateConfig(
804 GetTemplateConfig, pipelineConnections=GetDcrTemplateConnections
805):
806 numSubfilters = pexConfig.Field(
807 doc="Number of subfilters in the DcrCoadd.",
808 dtype=int,
809 default=3,
810 )
811 effectiveWavelength = pexConfig.Field(
812 doc="Effective wavelength of the filter in nm.",
813 optional=False,
814 dtype=float,
815 )
816 bandwidth = pexConfig.Field(
817 doc="Bandwidth of the physical filter.",
818 optional=False,
819 dtype=float,
820 )
821
822 def validate(self):
823 if self.effectiveWavelength is None or self.bandwidth is None:
824 raise ValueError(
825 "The effective wavelength and bandwidth of the physical filter "
826 "must be set in the getTemplate config for DCR coadds. "
827 "Required until transmission curves are used in DM-13668."
828 )
829
830
831class GetDcrTemplateTask(GetTemplateTask):
832 ConfigClass = GetDcrTemplateConfig
833 _DefaultName = "getDcrTemplate"
834
835 def runQuantum(self, butlerQC, inputRefs, outputRefs):
836 inputs = butlerQC.get(inputRefs)
837 bbox = inputs.pop("bbox")
838 wcs = inputs.pop("wcs")
839 dcrCoaddExposureHandles = inputs.pop("dcrCoadds")
840 skymap = inputs.pop("skyMap")
841 visitInfo = inputs.pop("visitInfo")
842
843 # This should not happen with a properly configured execution context.
844 assert not inputs, "runQuantum got more inputs than expected"
845
846 results = self.getExposures(
847 dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo
848 )
849 physical_filter = butlerQC.quantum.dataId["physical_filter"]
850 outputs = self.run(
851 coaddExposureHandles=results.coaddExposures,
852 bbox=bbox,
853 wcs=wcs,
854 dataIds=results.dataIds,
855 physical_filter=physical_filter,
856 )
857 butlerQC.put(outputs, outputRefs)
858
859 def getExposures(self, dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo):
860 """Return lists of coadds and their corresponding dataIds that overlap
861 the detector.
862
863 The spatial index in the registry has generous padding and often
864 supplies patches near, but not directly overlapping the detector.
865 Filters inputs so that we don't have to read in all input coadds.
866
867 Parameters
868 ----------
869 dcrCoaddExposureHandles : `list` \
870 [`lsst.daf.butler.DeferredDatasetHandle` of \
871 `lsst.afw.image.Exposure`]
872 Data references to exposures that might overlap the detector.
873 bbox : `lsst.geom.Box2I`
874 Template Bounding box of the detector geometry onto which to
875 resample the coaddExposures.
876 skymap : `lsst.skymap.SkyMap`
877 Input definition of geometry/bbox and projection/wcs for
878 template exposures.
879 wcs : `lsst.afw.geom.SkyWcs`
880 Template WCS onto which to resample the coaddExposures.
881 visitInfo : `lsst.afw.image.VisitInfo`
882 Metadata for the science image.
883
884 Returns
885 -------
886 result : `lsst.pipe.base.Struct`
887 A struct with attibutes:
888
889 ``coaddExposures``
890 Dict of coadd exposures that overlap the projected bbox,
891 indexed on tract id
892 (`dict` [`int`, `list` [`lsst.afw.image.Exposure`] ]).
893 ``dataIds``
894 Dict of data IDs of the coadd exposures that overlap the
895 projected bbox, indexed on tract id
896 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
897
898 Raises
899 ------
900 pipeBase.NoWorkFound
901 Raised if no patches overlatp the input detector bbox.
902 """
903 # Check that the patches actually overlap the detector
904 # Exposure's validPolygon would be more accurate
905 if wcs is None:
906 raise pipeBase.NoWorkFound("Exposure has no WCS; cannot create a template.")
907
908 detectorPolygon = geom.Box2D(bbox)
909 overlappingArea = 0
910 dataIds = collections.defaultdict(list)
911 patchList = dict()
912 for coaddRef in dcrCoaddExposureHandles:
913 dataId = coaddRef.dataId
914 subfilter = dataId["subfilter"]
915 patchWcs = skymap[dataId["tract"]].getWcs()
916 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
917 patchCorners = patchWcs.pixelToSky(geom.Box2D(patchBBox).getCorners())
918 patchPolygon = afwGeom.Polygon(wcs.skyToPixel(patchCorners))
919 if patchPolygon.intersection(detectorPolygon):
920 overlappingArea += patchPolygon.intersectionSingle(
921 detectorPolygon
922 ).calculateArea()
923 self.log.info(
924 "Using template input tract=%s, patch=%s, subfilter=%s"
925 % (dataId["tract"], dataId["patch"], dataId["subfilter"])
926 )
927 if dataId["tract"] in patchList:
928 patchList[dataId["tract"]].append(dataId["patch"])
929 else:
930 patchList[dataId["tract"]] = [
931 dataId["patch"],
932 ]
933 if subfilter == 0:
934 dataIds[dataId["tract"]].append(dataId)
935
936 if not overlappingArea:
937 raise pipeBase.NoWorkFound("No patches overlap detector")
938
939 self.checkPatchList(patchList)
940
941 coaddExposures = self.getDcrModel(patchList, dcrCoaddExposureHandles, visitInfo)
942 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
943
944 def checkPatchList(self, patchList):
945 """Check that all of the DcrModel subfilters are present for each
946 patch.
947
948 Parameters
949 ----------
950 patchList : `dict`
951 Dict of the patches containing valid data for each tract.
952
953 Raises
954 ------
955 RuntimeError
956 If the number of exposures found for a patch does not match the
957 number of subfilters.
958 """
959 for tract in patchList:
960 for patch in set(patchList[tract]):
961 if patchList[tract].count(patch) != self.config.numSubfilters:
962 raise RuntimeError(
963 "Invalid number of DcrModel subfilters found: %d vs %d expected",
964 patchList[tract].count(patch),
965 self.config.numSubfilters,
966 )
967
968 def getDcrModel(self, patchList, coaddRefs, visitInfo):
969 """Build DCR-matched coadds from a list of exposure references.
970
971 Parameters
972 ----------
973 patchList : `dict`
974 Dict of the patches containing valid data for each tract.
975 coaddRefs : `list` [`lsst.daf.butler.DeferredDatasetHandle`]
976 Data references to `~lsst.afw.image.Exposure` representing
977 DcrModels that overlap the detector.
978 visitInfo : `lsst.afw.image.VisitInfo`
979 Metadata for the science image.
980
981 Returns
982 -------
983 coaddExposures : `list` [`lsst.afw.image.Exposure`]
984 Coadd exposures that overlap the detector.
985 """
986 coaddExposures = collections.defaultdict(list)
987 for tract in patchList:
988 for patch in set(patchList[tract]):
989 coaddRefList = [
990 coaddRef
991 for coaddRef in coaddRefs
992 if _selectDataRef(coaddRef, tract, patch)
993 ]
994
995 dcrModel = DcrModel.fromQuantum(
996 coaddRefList,
997 self.config.effectiveWavelength,
998 self.config.bandwidth,
999 self.config.numSubfilters,
1000 )
1001 coaddExposures[tract].append(dcrModel.buildMatchedExposureHandle(visitInfo=visitInfo))
1002 return coaddExposures
1003
1004
1005def _selectDataRef(coaddRef, tract, patch):
1006 condition = (coaddRef.dataId["tract"] == tract) & (
1007 coaddRef.dataId["patch"] == patch
1008 )
1009 return condition
_correctVariance(self, template, plateScaleFactor)
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None)
checkHighVariance(self, template)
_makePsf(self, template, catalog, wcs)
_makeExposureCatalog(self, exposureRefs, dataIds)
_plateScaleFactor(self, coaddHandle, wcs, bbox)
getDcrModel(self, patchList, coaddRefs, visitInfo)
_merge(self, maskedImages, bbox, wcs)
checkPatchList(self, patchList)
_selectDataRef(coaddRef, tract, patch)